Skip to main content

rakata_generics/decoded/
utc.rs

1//! Decoded view over [`Utc`].
2//!
3//! Mirrors the two-stage shape established by [`UtiProjection`] /
4//! [`UtiSnapshot`] in [`super::uti`]. The projection wraps the source
5//! [`Utc`] reference for file-native access; the snapshot adds the
6//! resolved data the view's query methods read from. Coverage:
7//! scalar-id resolutions (race, appearance, portrait, soundset,
8//! alignment), typed [`DecodedClass`] variants over `ClassList`,
9//! typed [`DecodedSpecialAbility`] variants over `SpecAbilityList`,
10//! and typed [`DecodedFeat`] variants over `FeatList`.
11
12use rakata_extract::{tables, TwoDaCache};
13use rakata_formats::twoda::TwoDa;
14
15use crate::utc::{Utc, UtcClass, UtcEquipmentItem, UtcInventoryItem, UtcSpecialAbility};
16
17/// One decoded entry from a UTC's `FeatList`, dispatched by matching
18/// the `feat.2da` row's `label` cell against the set of vanilla feat
19/// labels the corpus references.
20///
21/// Vanilla `FeatList` entries carry a single `Feat: u16` field, so
22/// every typed variant has the same shape: a raw `feat_id` preserved
23/// from the source GFF. Dispatch is by `label`, not by raw `feat_id`,
24/// so a mod that re-numbers a vanilla feat still routes to its typed
25/// variant.
26///
27/// Mod-added rows, vanilla feat rows the corpus never assigns, and
28/// missing or out-of-range entries all surface as
29/// [`DecodedFeat::Unknown`] carrying the raw id and the resolved
30/// label (when reachable).
31// CLIPPY: feat family prefixes (`ArmourProf`, `WeaponProf`,
32// `WeaponFocus`, `WeaponSpec`, with `ForceJump`, `SneakAttack`,
33// `DroidUpgrade`, etc. arriving in later commits) are load-bearing
34// for grouping; the shared prefix is the feat family, not noise.
35#[allow(clippy::enum_variant_names)]
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum DecodedFeat {
38    /// `feat.2da` label `ARMOUR_PROF_LIGHT`. Vanilla row 5.
39    ArmourProfLight {
40        /// Raw `feat_id` (the GFF integer the dispatch resolved from).
41        feat_id: u16,
42    },
43    /// `feat.2da` label `ARMOUR_PROF_MEDIUM`. Vanilla row 6.
44    ArmourProfMedium {
45        /// Raw `feat_id`.
46        feat_id: u16,
47    },
48    /// `feat.2da` label `ARMOUR_PROF_HEAVY`. Vanilla row 4.
49    ArmourProfHeavy {
50        /// Raw `feat_id`.
51        feat_id: u16,
52    },
53    /// `feat.2da` label `WEAPON_PROF_BLASTER`. Vanilla row 39.
54    WeaponProfBlaster {
55        /// Raw `feat_id`.
56        feat_id: u16,
57    },
58    /// `feat.2da` label `WEAPON_PROF_BLASTER_RIFLE`. Vanilla row 40.
59    WeaponProfBlasterRifle {
60        /// Raw `feat_id`.
61        feat_id: u16,
62    },
63    /// `feat.2da` label `WEAPON_PROF_HEAVY_WEAPONS`. Vanilla row 42.
64    WeaponProfHeavyWeapons {
65        /// Raw `feat_id`.
66        feat_id: u16,
67    },
68    /// `feat.2da` label `WEAPON_PROF_MELEE_WEAPONS`. Vanilla row 44.
69    WeaponProfMeleeWeapons {
70        /// Raw `feat_id`.
71        feat_id: u16,
72    },
73    /// `feat.2da` label `WEAPON_PROF_LIGHTSABER`. Vanilla row 43.
74    WeaponProfLightsaber {
75        /// Raw `feat_id`.
76        feat_id: u16,
77    },
78    /// `feat.2da` label `XXXX_WEAPON_PROF_GRENADE` (the `XXXX_`
79    /// prefix marks the feat as deprecated by Bioware; vanilla UTCs
80    /// still load it). Vanilla row 41.
81    WeaponProfGrenade {
82        /// Raw `feat_id`.
83        feat_id: u16,
84    },
85    /// `feat.2da` label `XXXX_WEAPON_PROF_SIMPLE_WEAPONS`. Vanilla
86    /// row 45.
87    WeaponProfSimpleWeapons {
88        /// Raw `feat_id`.
89        feat_id: u16,
90    },
91    /// `feat.2da` label `PROFICIENCY_ALL`. Vanilla row 93. The
92    /// catch-all proficiency granted to special and cutscene
93    /// creatures.
94    ProficiencyAll {
95        /// Raw `feat_id`.
96        feat_id: u16,
97    },
98    /// `feat.2da` label `WEAPON_FOCUS_BLASTER`. Vanilla row 32.
99    WeaponFocusBlaster {
100        /// Raw `feat_id`.
101        feat_id: u16,
102    },
103    /// `feat.2da` label `WEAPON_FOCUS_BLASTER_RIFLE`. Vanilla row 33.
104    WeaponFocusBlasterRifle {
105        /// Raw `feat_id`.
106        feat_id: u16,
107    },
108    /// `feat.2da` label `XXXX_WEAPON_FOCUS_GRENADE`. Vanilla row 34.
109    WeaponFocusGrenade {
110        /// Raw `feat_id`.
111        feat_id: u16,
112    },
113    /// `feat.2da` label `WEAPON_FOCUS_HEAVY_WEAPONS`. Vanilla row 35.
114    WeaponFocusHeavyWeapons {
115        /// Raw `feat_id`.
116        feat_id: u16,
117    },
118    /// `feat.2da` label `WEAPON_FOCUS_LIGHTSABER`. Vanilla row 36.
119    WeaponFocusLightsaber {
120        /// Raw `feat_id`.
121        feat_id: u16,
122    },
123    /// `feat.2da` label `WEAPON_FOCUS_MELEE_WEAPONS`. Vanilla row 37.
124    WeaponFocusMeleeWeapons {
125        /// Raw `feat_id`.
126        feat_id: u16,
127    },
128    /// `feat.2da` label `XXXX_WEAPON_FOCUS_SIMPLE_WEAPONS`. Vanilla
129    /// row 38.
130    WeaponFocusSimpleWeapons {
131        /// Raw `feat_id`.
132        feat_id: u16,
133    },
134    /// `feat.2da` label `WEAPON_SPEC_BLASTER`. Vanilla row 46.
135    WeaponSpecBlaster {
136        /// Raw `feat_id`.
137        feat_id: u16,
138    },
139    /// `feat.2da` label `WEAPON_SPEC_BLASTER_RIFLE`. Vanilla row 47.
140    WeaponSpecBlasterRifle {
141        /// Raw `feat_id`.
142        feat_id: u16,
143    },
144    /// `feat.2da` label `XXXX_WEAPON_SPEC_GRENADE`. Vanilla row 48.
145    WeaponSpecGrenade {
146        /// Raw `feat_id`.
147        feat_id: u16,
148    },
149    /// `feat.2da` label `WEAPON_SPEC_HEAVY_WEAPONS`. Vanilla row 49.
150    WeaponSpecHeavyWeapons {
151        /// Raw `feat_id`.
152        feat_id: u16,
153    },
154    /// `feat.2da` label `WEAPON_SPEC_LIGHTSABER`. Vanilla row 50.
155    WeaponSpecLightsaber {
156        /// Raw `feat_id`.
157        feat_id: u16,
158    },
159    /// `feat.2da` label `WEAPON_SPEC_MELEE_WEAPONS`. Vanilla row 51.
160    WeaponSpecMeleeWeapons {
161        /// Raw `feat_id`.
162        feat_id: u16,
163    },
164    /// `feat.2da` label `XXXX_WEAPON_SPEC_SIMPLE_WEAPONS`. Vanilla
165    /// row 52.
166    WeaponSpecSimpleWeapons {
167        /// Raw `feat_id`.
168        feat_id: u16,
169    },
170    /// `feat.2da` label `POWER_ATTACK`. Vanilla row 28.
171    PowerAttack {
172        /// Raw `feat_id`.
173        feat_id: u16,
174    },
175    /// `feat.2da` label `IMPROVED_POWER_ATTACK`. Vanilla row 17.
176    ImprovedPowerAttack {
177        /// Raw `feat_id`.
178        feat_id: u16,
179    },
180    /// `feat.2da` label `MASTER_POWER_ATTACK`. Vanilla row 83.
181    MasterPowerAttack {
182        /// Raw `feat_id`.
183        feat_id: u16,
184    },
185    /// `feat.2da` label `POWER_BLAST`. Vanilla row 29.
186    PowerBlast {
187        /// Raw `feat_id`.
188        feat_id: u16,
189    },
190    /// `feat.2da` label `IMPROVED_POWER_BLAST`. Vanilla row 18.
191    ImprovedPowerBlast {
192        /// Raw `feat_id`.
193        feat_id: u16,
194    },
195    /// `feat.2da` label `MASTER_POWER_BLAST`. Vanilla row 82.
196    MasterPowerBlast {
197        /// Raw `feat_id`.
198        feat_id: u16,
199    },
200    /// `feat.2da` label `CRITICAL_STRIKE`. Vanilla row 8.
201    CriticalStrike {
202        /// Raw `feat_id`.
203        feat_id: u16,
204    },
205    /// `feat.2da` label `IMPROVED_CRITICAL_STRIKE`. Vanilla row 19.
206    ImprovedCriticalStrike {
207        /// Raw `feat_id`.
208        feat_id: u16,
209    },
210    /// `feat.2da` label `MASTER_CRITICAL_STRIKE`. Vanilla row 81.
211    MasterCriticalStrike {
212        /// Raw `feat_id`.
213        feat_id: u16,
214    },
215    /// `feat.2da` label `FLURRY`. Vanilla row 11.
216    Flurry {
217        /// Raw `feat_id`.
218        feat_id: u16,
219    },
220    /// `feat.2da` label `IMPROVED_FLURRY`. Vanilla row 91.
221    ImprovedFlurry {
222        /// Raw `feat_id`.
223        feat_id: u16,
224    },
225    /// `feat.2da` label `SNIPER_SHOT`. Vanilla row 31.
226    SniperShot {
227        /// Raw `feat_id`.
228        feat_id: u16,
229    },
230    /// `feat.2da` label `IMPROVED_SNIPER_SHOT`. Vanilla row 20.
231    ImprovedSniperShot {
232        /// Raw `feat_id`.
233        feat_id: u16,
234    },
235    /// `feat.2da` label `MASTER_SNIPER_SHOT`. Vanilla row 77.
236    MasterSniperShot {
237        /// Raw `feat_id`.
238        feat_id: u16,
239    },
240    /// `feat.2da` label `RAPID_SHOT`. Vanilla row 30.
241    RapidShot {
242        /// Raw `feat_id`.
243        feat_id: u16,
244    },
245    /// `feat.2da` label `IMPROVED_RAPID_SHOT`. Vanilla row 92.
246    ImprovedRapidShot {
247        /// Raw `feat_id`.
248        feat_id: u16,
249    },
250    /// `feat.2da` label `MULTI_SHOT`. Vanilla row 26.
251    MultiShot {
252        /// Raw `feat_id`.
253        feat_id: u16,
254    },
255    /// `feat.2da` label `WHIRLWIND_ATTACK`. Vanilla row 53.
256    WhirlwindAttack {
257        /// Raw `feat_id`.
258        feat_id: u16,
259    },
260    /// `feat.2da` label `TWO_WEAPON_FIGHTING`. Vanilla row 3.
261    TwoWeaponFighting {
262        /// Raw `feat_id`.
263        feat_id: u16,
264    },
265    /// `feat.2da` label `TWO_WEAPON_ADVANCED`. Vanilla row 9.
266    TwoWeaponAdvanced {
267        /// Raw `feat_id`.
268        feat_id: u16,
269    },
270    /// `feat.2da` label `TWO_WEAPON_MASTERY`. Vanilla row 85.
271    TwoWeaponMastery {
272        /// Raw `feat_id`.
273        feat_id: u16,
274    },
275    /// `feat.2da` label `JEDI_DEFENSE`. Vanilla row 55.
276    JediDefense {
277        /// Raw `feat_id`.
278        feat_id: u16,
279    },
280    /// `feat.2da` label `ADVANCED_JEDI_DEFENSE`. Vanilla row 1.
281    AdvancedJediDefense {
282        /// Raw `feat_id`.
283        feat_id: u16,
284    },
285    /// `feat.2da` label `MASTER_JEDI_DEFENSE`. Vanilla row 24.
286    MasterJediDefense {
287        /// Raw `feat_id`.
288        feat_id: u16,
289    },
290    /// `feat.2da` label `FORCE_FOCUS`. Vanilla row 88. Sense-school
291    /// focus tier.
292    ForceFocus {
293        /// Raw `feat_id`.
294        feat_id: u16,
295    },
296    /// `feat.2da` label `FORCE_FOCUS_ADVANCED`. Vanilla row 89.
297    ForceFocusAdvanced {
298        /// Raw `feat_id`.
299        feat_id: u16,
300    },
301    /// `feat.2da` label `FORCE_FOCUS_MASTERY`. Vanilla row 90.
302    ForceFocusMastery {
303        /// Raw `feat_id`.
304        feat_id: u16,
305    },
306    /// `feat.2da` label `XXXX_FORCE_FOCUS_ALTER`. Vanilla row 86.
307    /// Alter-school focus, deprecated by Bioware but still loaded.
308    ForceFocusAlter {
309        /// Raw `feat_id`.
310        feat_id: u16,
311    },
312    /// `feat.2da` label `XXXX_FORCE_FOCUS_CONTROL`. Vanilla row 87.
313    /// Control-school focus, deprecated by Bioware but still loaded.
314    ForceFocusControl {
315        /// Raw `feat_id`.
316        feat_id: u16,
317    },
318    /// `feat.2da` label `FORCE_JUMP`. Vanilla row 101.
319    ForceJump {
320        /// Raw `feat_id`.
321        feat_id: u16,
322    },
323    /// `feat.2da` label `FORCE_JUMP_ADVANCED`. Vanilla row 102.
324    ForceJumpAdvanced {
325        /// Raw `feat_id`.
326        feat_id: u16,
327    },
328    /// `feat.2da` label `FORCE_JUMP_MASTERY`. Vanilla row 103.
329    ForceJumpMastery {
330        /// Raw `feat_id`.
331        feat_id: u16,
332    },
333    /// `feat.2da` label `FORCE_IMMUNITY_FEAR`. Vanilla row 98.
334    ForceImmunityFear {
335        /// Raw `feat_id`.
336        feat_id: u16,
337    },
338    /// `feat.2da` label `FORCE_IMMUNITY_STUN`. Vanilla row 99.
339    ForceImmunityStun {
340        /// Raw `feat_id`.
341        feat_id: u16,
342    },
343    /// `feat.2da` label `FORCE_IMMUNITY_PARALYSIS`. Vanilla row 100.
344    ForceImmunityParalysis {
345        /// Raw `feat_id`.
346        feat_id: u16,
347    },
348    /// `feat.2da` label `BATTLE_MEDITATION`. Vanilla row 94. Bastila
349    /// signature feat.
350    BattleMeditation {
351        /// Raw `feat_id`.
352        feat_id: u16,
353    },
354    /// `feat.2da` label `FORCE_CAMOFLAGE`. Vanilla row 97. Spelling
355    /// reflects the vanilla label, which is missing the second `u`.
356    ForceCamoflage {
357        /// Raw `feat_id`.
358        feat_id: u16,
359    },
360    /// `feat.2da` label `SNEAK_ATTACK_1D6`. Vanilla row 60.
361    SneakAttack1D6 {
362        /// Raw `feat_id`.
363        feat_id: u16,
364    },
365    /// `feat.2da` label `SNEAK_ATTACK_2D6`. Vanilla row 61.
366    SneakAttack2D6 {
367        /// Raw `feat_id`.
368        feat_id: u16,
369    },
370    /// `feat.2da` label `SNEAK_ATTACK_3D6`. Vanilla row 62.
371    SneakAttack3D6 {
372        /// Raw `feat_id`.
373        feat_id: u16,
374    },
375    /// `feat.2da` label `SNEAK_ATTACK_4D6`. Vanilla row 63.
376    SneakAttack4D6 {
377        /// Raw `feat_id`.
378        feat_id: u16,
379    },
380    /// `feat.2da` label `SNEAK_ATTACK_5D6`. Vanilla row 64.
381    SneakAttack5D6 {
382        /// Raw `feat_id`.
383        feat_id: u16,
384    },
385    /// `feat.2da` label `SNEAK_ATTACK_6D6`. Vanilla row 65.
386    SneakAttack6D6 {
387        /// Raw `feat_id`.
388        feat_id: u16,
389    },
390    /// `feat.2da` label `UNCANNY_DODGE_1`. Vanilla row 56.
391    UncannyDodge1 {
392        /// Raw `feat_id`.
393        feat_id: u16,
394    },
395    /// `feat.2da` label `UNCANNY_DODGE_2`. Vanilla row 57.
396    UncannyDodge2 {
397        /// Raw `feat_id`.
398        feat_id: u16,
399    },
400    /// `feat.2da` label `TOUGHNESS`. Vanilla row 84.
401    Toughness {
402        /// Raw `feat_id`.
403        feat_id: u16,
404    },
405    /// `feat.2da` label `IMPROVED_TOUGHNESS`. Vanilla row 123.
406    ImprovedToughness {
407        /// Raw `feat_id`.
408        feat_id: u16,
409    },
410    /// `feat.2da` label `MASTER_TOUGHNESS`. Vanilla row 124.
411    MasterToughness {
412        /// Raw `feat_id`.
413        feat_id: u16,
414    },
415    /// `feat.2da` label `CONDITIONING`. Vanilla row 13.
416    Conditioning {
417        /// Raw `feat_id`.
418        feat_id: u16,
419    },
420    /// `feat.2da` label `IMPROVED_CONDITIONING`. Vanilla row 21.
421    ImprovedConditioning {
422        /// Raw `feat_id`.
423        feat_id: u16,
424    },
425    /// `feat.2da` label `MASTER_CONDITIONING`. Vanilla row 22.
426    MasterConditioning {
427        /// Raw `feat_id`.
428        feat_id: u16,
429    },
430    /// `feat.2da` label `IMPLANT_LEVEL_1`. Vanilla row 14.
431    ImplantLevel1 {
432        /// Raw `feat_id`.
433        feat_id: u16,
434    },
435    /// `feat.2da` label `IMPLANT_LEVEL_2`. Vanilla row 15.
436    ImplantLevel2 {
437        /// Raw `feat_id`.
438        feat_id: u16,
439    },
440    /// `feat.2da` label `IMPLANT_LEVEL_3`. Vanilla row 16.
441    ImplantLevel3 {
442        /// Raw `feat_id`.
443        feat_id: u16,
444    },
445    /// `feat.2da` label `DROID_UPGRADE_1`. Vanilla row 78.
446    DroidUpgrade1 {
447        /// Raw `feat_id`.
448        feat_id: u16,
449    },
450    /// `feat.2da` label `DROID_UPGRADE_2`. Vanilla row 79.
451    DroidUpgrade2 {
452        /// Raw `feat_id`.
453        feat_id: u16,
454    },
455    /// `feat.2da` label `DROID_UPGRADE_3`. Vanilla row 80.
456    DroidUpgrade3 {
457        /// Raw `feat_id`.
458        feat_id: u16,
459    },
460    /// `feat.2da` label `BLASTER_INTEGRATION`. Vanilla row 96. Droid
461    /// combat integration upgrade.
462    BlasterIntegration {
463        /// Raw `feat_id`.
464        feat_id: u16,
465    },
466    /// `feat.2da` label `LOGIC_UPGRADE_COMBAT`. Vanilla row 110.
467    LogicUpgradeCombat {
468        /// Raw `feat_id`.
469        feat_id: u16,
470    },
471    /// `feat.2da` label `LOGIC_UPGRADE_TACTICIAN`. Vanilla row 111.
472    LogicUpgradeTactician {
473        /// Raw `feat_id`.
474        feat_id: u16,
475    },
476    /// `feat.2da` label `DUELING`. Vanilla row 113.
477    Dueling {
478        /// Raw `feat_id`.
479        feat_id: u16,
480    },
481    /// `feat.2da` label `ADVANCED_DUELING`. Vanilla row 114.
482    AdvancedDueling {
483        /// Raw `feat_id`.
484        feat_id: u16,
485    },
486    /// `feat.2da` label `MASTER_DUELING`. Vanilla row 115.
487    MasterDueling {
488        /// Raw `feat_id`.
489        feat_id: u16,
490    },
491    /// `feat.2da` label `JEDI_SENSE`. Vanilla row 107. Padawan-tier
492    /// danger sense feat.
493    JediSense {
494        /// Raw `feat_id`.
495        feat_id: u16,
496    },
497    /// `feat.2da` label `KNIGHT_SENSE`. Vanilla row 108.
498    KnightSense {
499        /// Raw `feat_id`.
500        feat_id: u16,
501    },
502    /// `feat.2da` label `MASTER_SENSE`. Vanilla row 109.
503    MasterSense {
504        /// Raw `feat_id`.
505        feat_id: u16,
506    },
507    /// `feat.2da` label `XXXX_GUARD_STANCE`. Vanilla row 54. Cut
508    /// feat still wired into vanilla UTCs.
509    GuardStance {
510        /// Raw `feat_id`.
511        feat_id: u16,
512    },
513    /// `feat.2da` label `XXXX_ADVANCED_GUARD_STANCE`. Vanilla row 2.
514    AdvancedGuardStance {
515        /// Raw `feat_id`.
516        feat_id: u16,
517    },
518    /// `feat.2da` label `XXXX_MASTER_GUARD_STANCE`. Vanilla row 25.
519    MasterGuardStance {
520        /// Raw `feat_id`.
521        feat_id: u16,
522    },
523    /// `feat.2da` label `XXXX_SKILL_FOCUS_AWARENESS`. Vanilla row 72.
524    /// Deprecated skill-focus feat still wired into a handful of
525    /// vanilla UTCs.
526    SkillFocusAwareness {
527        /// Raw `feat_id`.
528        feat_id: u16,
529    },
530    /// `feat.2da` label `XXXX_SKILL_FOCUS_TREAT_INJURY`. Vanilla
531    /// row 76.
532    SkillFocusTreatInjury {
533        /// Raw `feat_id`.
534        feat_id: u16,
535    },
536    /// `feat.2da` label `XXXX_SKILL_FOCUS_STEALTH`. Vanilla row 71.
537    SkillFocusStealth {
538        /// Raw `feat_id`.
539        feat_id: u16,
540    },
541    /// `feat.2da` label `CAUTIOUS`. Vanilla row 7. Save bonus
542    /// granted to specific encounter NPCs.
543    Cautious {
544        /// Raw `feat_id`.
545        feat_id: u16,
546    },
547    /// `feat.2da` label `XXXX_PERCEPTIVE`. Vanilla row 27. Cut feat
548    /// still wired into a handful of vanilla UTCs.
549    Perceptive {
550        /// Raw `feat_id`.
551        feat_id: u16,
552    },
553    /// `feat.2da` label `EMPATHY`. Vanilla row 10.
554    Empathy {
555        /// Raw `feat_id`.
556        feat_id: u16,
557    },
558    /// `feat.2da` label `GEAR_HEAD`. Vanilla row 12.
559    GearHead {
560        /// Raw `feat_id`.
561        feat_id: u16,
562    },
563    /// `feat.2da` label `WOOKIE_ENDURANCE`. Vanilla row 95.
564    WookieEndurance {
565        /// Raw `feat_id`.
566        feat_id: u16,
567    },
568    /// `feat.2da` label `SCOUNDRELS_LUCK`. Vanilla row 104.
569    ScoundrelsLuck {
570        /// Raw `feat_id`.
571        feat_id: u16,
572    },
573    /// Feat identity could not be matched to any typed variant.
574    /// Carries the raw `feat_id` plus the resolved label when
575    /// reachable so consumers can still display something useful for
576    /// mod-added or untyped feats.
577    Unknown {
578        /// Raw `feat_id` from the GFF.
579        feat_id: u16,
580        /// Resolved `label` cell from `feat.2da` at row `feat_id`,
581        /// or `None` when the table is unavailable, the row does not
582        /// exist, or the label cell is missing.
583        feat_label: Option<String>,
584    },
585}
586
587impl DecodedFeat {
588    /// Returns the raw `feat_id` regardless of variant.
589    pub fn feat_id(&self) -> u16 {
590        match self {
591            Self::ArmourProfLight { feat_id }
592            | Self::ArmourProfMedium { feat_id }
593            | Self::ArmourProfHeavy { feat_id }
594            | Self::WeaponProfBlaster { feat_id }
595            | Self::WeaponProfBlasterRifle { feat_id }
596            | Self::WeaponProfHeavyWeapons { feat_id }
597            | Self::WeaponProfMeleeWeapons { feat_id }
598            | Self::WeaponProfLightsaber { feat_id }
599            | Self::WeaponProfGrenade { feat_id }
600            | Self::WeaponProfSimpleWeapons { feat_id }
601            | Self::ProficiencyAll { feat_id }
602            | Self::WeaponFocusBlaster { feat_id }
603            | Self::WeaponFocusBlasterRifle { feat_id }
604            | Self::WeaponFocusGrenade { feat_id }
605            | Self::WeaponFocusHeavyWeapons { feat_id }
606            | Self::WeaponFocusLightsaber { feat_id }
607            | Self::WeaponFocusMeleeWeapons { feat_id }
608            | Self::WeaponFocusSimpleWeapons { feat_id }
609            | Self::WeaponSpecBlaster { feat_id }
610            | Self::WeaponSpecBlasterRifle { feat_id }
611            | Self::WeaponSpecGrenade { feat_id }
612            | Self::WeaponSpecHeavyWeapons { feat_id }
613            | Self::WeaponSpecLightsaber { feat_id }
614            | Self::WeaponSpecMeleeWeapons { feat_id }
615            | Self::WeaponSpecSimpleWeapons { feat_id }
616            | Self::PowerAttack { feat_id }
617            | Self::ImprovedPowerAttack { feat_id }
618            | Self::MasterPowerAttack { feat_id }
619            | Self::PowerBlast { feat_id }
620            | Self::ImprovedPowerBlast { feat_id }
621            | Self::MasterPowerBlast { feat_id }
622            | Self::CriticalStrike { feat_id }
623            | Self::ImprovedCriticalStrike { feat_id }
624            | Self::MasterCriticalStrike { feat_id }
625            | Self::Flurry { feat_id }
626            | Self::ImprovedFlurry { feat_id }
627            | Self::SniperShot { feat_id }
628            | Self::ImprovedSniperShot { feat_id }
629            | Self::MasterSniperShot { feat_id }
630            | Self::RapidShot { feat_id }
631            | Self::ImprovedRapidShot { feat_id }
632            | Self::MultiShot { feat_id }
633            | Self::WhirlwindAttack { feat_id }
634            | Self::TwoWeaponFighting { feat_id }
635            | Self::TwoWeaponAdvanced { feat_id }
636            | Self::TwoWeaponMastery { feat_id }
637            | Self::JediDefense { feat_id }
638            | Self::AdvancedJediDefense { feat_id }
639            | Self::MasterJediDefense { feat_id }
640            | Self::ForceFocus { feat_id }
641            | Self::ForceFocusAdvanced { feat_id }
642            | Self::ForceFocusMastery { feat_id }
643            | Self::ForceFocusAlter { feat_id }
644            | Self::ForceFocusControl { feat_id }
645            | Self::ForceJump { feat_id }
646            | Self::ForceJumpAdvanced { feat_id }
647            | Self::ForceJumpMastery { feat_id }
648            | Self::ForceImmunityFear { feat_id }
649            | Self::ForceImmunityStun { feat_id }
650            | Self::ForceImmunityParalysis { feat_id }
651            | Self::BattleMeditation { feat_id }
652            | Self::ForceCamoflage { feat_id }
653            | Self::SneakAttack1D6 { feat_id }
654            | Self::SneakAttack2D6 { feat_id }
655            | Self::SneakAttack3D6 { feat_id }
656            | Self::SneakAttack4D6 { feat_id }
657            | Self::SneakAttack5D6 { feat_id }
658            | Self::SneakAttack6D6 { feat_id }
659            | Self::UncannyDodge1 { feat_id }
660            | Self::UncannyDodge2 { feat_id }
661            | Self::Toughness { feat_id }
662            | Self::ImprovedToughness { feat_id }
663            | Self::MasterToughness { feat_id }
664            | Self::Conditioning { feat_id }
665            | Self::ImprovedConditioning { feat_id }
666            | Self::MasterConditioning { feat_id }
667            | Self::ImplantLevel1 { feat_id }
668            | Self::ImplantLevel2 { feat_id }
669            | Self::ImplantLevel3 { feat_id }
670            | Self::DroidUpgrade1 { feat_id }
671            | Self::DroidUpgrade2 { feat_id }
672            | Self::DroidUpgrade3 { feat_id }
673            | Self::BlasterIntegration { feat_id }
674            | Self::LogicUpgradeCombat { feat_id }
675            | Self::LogicUpgradeTactician { feat_id }
676            | Self::Dueling { feat_id }
677            | Self::AdvancedDueling { feat_id }
678            | Self::MasterDueling { feat_id }
679            | Self::JediSense { feat_id }
680            | Self::KnightSense { feat_id }
681            | Self::MasterSense { feat_id }
682            | Self::GuardStance { feat_id }
683            | Self::AdvancedGuardStance { feat_id }
684            | Self::MasterGuardStance { feat_id }
685            | Self::SkillFocusAwareness { feat_id }
686            | Self::SkillFocusTreatInjury { feat_id }
687            | Self::SkillFocusStealth { feat_id }
688            | Self::Cautious { feat_id }
689            | Self::Perceptive { feat_id }
690            | Self::Empathy { feat_id }
691            | Self::GearHead { feat_id }
692            | Self::WookieEndurance { feat_id }
693            | Self::ScoundrelsLuck { feat_id }
694            | Self::Unknown { feat_id, .. } => *feat_id,
695        }
696    }
697}
698
699/// Resolves a `feat.2da` row label for a UTC feat entry, then
700/// dispatches into the typed [`DecodedFeat`] variant or `Unknown`.
701///
702/// Dispatch is by `label`, not by raw `feat_id`, so a mod that
703/// re-numbers a vanilla feat still routes correctly. A missing
704/// `feat.2da`, an out-of-range `feat_id`, an absent `label` cell, or
705/// a label unknown to the dispatch table all surface as
706/// [`DecodedFeat::Unknown`] carrying whichever raw fields and
707/// resolved label could be recovered.
708fn dispatch_feat(feat_id: u16, feat_table: Option<&TwoDa>) -> DecodedFeat {
709    let label = feat_table.and_then(|table| {
710        let row = usize::from(feat_id);
711        if row >= table.rows.len() {
712            return None;
713        }
714        table.cell(row, "label").map(str::to_string)
715    });
716    match label.as_deref() {
717        // Armour proficiency.
718        Some("ARMOUR_PROF_LIGHT") => DecodedFeat::ArmourProfLight { feat_id },
719        Some("ARMOUR_PROF_MEDIUM") => DecodedFeat::ArmourProfMedium { feat_id },
720        Some("ARMOUR_PROF_HEAVY") => DecodedFeat::ArmourProfHeavy { feat_id },
721        // Weapon proficiency. XXXX_* labels are vanilla-deprecated
722        // markers; the feats themselves still load.
723        Some("WEAPON_PROF_BLASTER") => DecodedFeat::WeaponProfBlaster { feat_id },
724        Some("WEAPON_PROF_BLASTER_RIFLE") => DecodedFeat::WeaponProfBlasterRifle { feat_id },
725        Some("WEAPON_PROF_HEAVY_WEAPONS") => DecodedFeat::WeaponProfHeavyWeapons { feat_id },
726        Some("WEAPON_PROF_MELEE_WEAPONS") => DecodedFeat::WeaponProfMeleeWeapons { feat_id },
727        Some("WEAPON_PROF_LIGHTSABER") => DecodedFeat::WeaponProfLightsaber { feat_id },
728        Some("XXXX_WEAPON_PROF_GRENADE") => DecodedFeat::WeaponProfGrenade { feat_id },
729        Some("XXXX_WEAPON_PROF_SIMPLE_WEAPONS") => DecodedFeat::WeaponProfSimpleWeapons { feat_id },
730        // Catch-all proficiency, granted to special / cutscene creatures.
731        Some("PROFICIENCY_ALL") => DecodedFeat::ProficiencyAll { feat_id },
732        // Weapon focus.
733        Some("WEAPON_FOCUS_BLASTER") => DecodedFeat::WeaponFocusBlaster { feat_id },
734        Some("WEAPON_FOCUS_BLASTER_RIFLE") => DecodedFeat::WeaponFocusBlasterRifle { feat_id },
735        Some("XXXX_WEAPON_FOCUS_GRENADE") => DecodedFeat::WeaponFocusGrenade { feat_id },
736        Some("WEAPON_FOCUS_HEAVY_WEAPONS") => DecodedFeat::WeaponFocusHeavyWeapons { feat_id },
737        Some("WEAPON_FOCUS_LIGHTSABER") => DecodedFeat::WeaponFocusLightsaber { feat_id },
738        Some("WEAPON_FOCUS_MELEE_WEAPONS") => DecodedFeat::WeaponFocusMeleeWeapons { feat_id },
739        Some("XXXX_WEAPON_FOCUS_SIMPLE_WEAPONS") => {
740            DecodedFeat::WeaponFocusSimpleWeapons { feat_id }
741        }
742        // Weapon specialization.
743        Some("WEAPON_SPEC_BLASTER") => DecodedFeat::WeaponSpecBlaster { feat_id },
744        Some("WEAPON_SPEC_BLASTER_RIFLE") => DecodedFeat::WeaponSpecBlasterRifle { feat_id },
745        Some("XXXX_WEAPON_SPEC_GRENADE") => DecodedFeat::WeaponSpecGrenade { feat_id },
746        Some("WEAPON_SPEC_HEAVY_WEAPONS") => DecodedFeat::WeaponSpecHeavyWeapons { feat_id },
747        Some("WEAPON_SPEC_LIGHTSABER") => DecodedFeat::WeaponSpecLightsaber { feat_id },
748        Some("WEAPON_SPEC_MELEE_WEAPONS") => DecodedFeat::WeaponSpecMeleeWeapons { feat_id },
749        Some("XXXX_WEAPON_SPEC_SIMPLE_WEAPONS") => DecodedFeat::WeaponSpecSimpleWeapons { feat_id },
750        // Power Attack ladder.
751        Some("POWER_ATTACK") => DecodedFeat::PowerAttack { feat_id },
752        Some("IMPROVED_POWER_ATTACK") => DecodedFeat::ImprovedPowerAttack { feat_id },
753        Some("MASTER_POWER_ATTACK") => DecodedFeat::MasterPowerAttack { feat_id },
754        // Power Blast ladder.
755        Some("POWER_BLAST") => DecodedFeat::PowerBlast { feat_id },
756        Some("IMPROVED_POWER_BLAST") => DecodedFeat::ImprovedPowerBlast { feat_id },
757        Some("MASTER_POWER_BLAST") => DecodedFeat::MasterPowerBlast { feat_id },
758        // Critical Strike ladder.
759        Some("CRITICAL_STRIKE") => DecodedFeat::CriticalStrike { feat_id },
760        Some("IMPROVED_CRITICAL_STRIKE") => DecodedFeat::ImprovedCriticalStrike { feat_id },
761        Some("MASTER_CRITICAL_STRIKE") => DecodedFeat::MasterCriticalStrike { feat_id },
762        // Flurry ladder.
763        Some("FLURRY") => DecodedFeat::Flurry { feat_id },
764        Some("IMPROVED_FLURRY") => DecodedFeat::ImprovedFlurry { feat_id },
765        // Sniper Shot ladder.
766        Some("SNIPER_SHOT") => DecodedFeat::SniperShot { feat_id },
767        Some("IMPROVED_SNIPER_SHOT") => DecodedFeat::ImprovedSniperShot { feat_id },
768        Some("MASTER_SNIPER_SHOT") => DecodedFeat::MasterSniperShot { feat_id },
769        // Rapid Shot ladder.
770        Some("RAPID_SHOT") => DecodedFeat::RapidShot { feat_id },
771        Some("IMPROVED_RAPID_SHOT") => DecodedFeat::ImprovedRapidShot { feat_id },
772        // Singletons (no improved or master tier in vanilla).
773        Some("MULTI_SHOT") => DecodedFeat::MultiShot { feat_id },
774        Some("WHIRLWIND_ATTACK") => DecodedFeat::WhirlwindAttack { feat_id },
775        // Two-Weapon ladder.
776        Some("TWO_WEAPON_FIGHTING") => DecodedFeat::TwoWeaponFighting { feat_id },
777        Some("TWO_WEAPON_ADVANCED") => DecodedFeat::TwoWeaponAdvanced { feat_id },
778        Some("TWO_WEAPON_MASTERY") => DecodedFeat::TwoWeaponMastery { feat_id },
779        // Jedi defensive ladder.
780        Some("JEDI_DEFENSE") => DecodedFeat::JediDefense { feat_id },
781        Some("ADVANCED_JEDI_DEFENSE") => DecodedFeat::AdvancedJediDefense { feat_id },
782        Some("MASTER_JEDI_DEFENSE") => DecodedFeat::MasterJediDefense { feat_id },
783        // Force focus ladder (sense / alter / control schools).
784        Some("FORCE_FOCUS") => DecodedFeat::ForceFocus { feat_id },
785        Some("FORCE_FOCUS_ADVANCED") => DecodedFeat::ForceFocusAdvanced { feat_id },
786        Some("FORCE_FOCUS_MASTERY") => DecodedFeat::ForceFocusMastery { feat_id },
787        Some("XXXX_FORCE_FOCUS_ALTER") => DecodedFeat::ForceFocusAlter { feat_id },
788        Some("XXXX_FORCE_FOCUS_CONTROL") => DecodedFeat::ForceFocusControl { feat_id },
789        // Force jump ladder.
790        Some("FORCE_JUMP") => DecodedFeat::ForceJump { feat_id },
791        Some("FORCE_JUMP_ADVANCED") => DecodedFeat::ForceJumpAdvanced { feat_id },
792        Some("FORCE_JUMP_MASTERY") => DecodedFeat::ForceJumpMastery { feat_id },
793        // Force immunity trio.
794        Some("FORCE_IMMUNITY_FEAR") => DecodedFeat::ForceImmunityFear { feat_id },
795        Some("FORCE_IMMUNITY_STUN") => DecodedFeat::ForceImmunityStun { feat_id },
796        Some("FORCE_IMMUNITY_PARALYSIS") => DecodedFeat::ForceImmunityParalysis { feat_id },
797        // Force singletons.
798        Some("BATTLE_MEDITATION") => DecodedFeat::BattleMeditation { feat_id },
799        // Vanilla label is missing the second `u`; preserved verbatim.
800        Some("FORCE_CAMOFLAGE") => DecodedFeat::ForceCamoflage { feat_id },
801        // Sneak attack ladder. Per-tier variants rather than a
802        // dice-count enum because the engine treats each tier as an
803        // independent feat with its own prereq column.
804        Some("SNEAK_ATTACK_1D6") => DecodedFeat::SneakAttack1D6 { feat_id },
805        Some("SNEAK_ATTACK_2D6") => DecodedFeat::SneakAttack2D6 { feat_id },
806        Some("SNEAK_ATTACK_3D6") => DecodedFeat::SneakAttack3D6 { feat_id },
807        Some("SNEAK_ATTACK_4D6") => DecodedFeat::SneakAttack4D6 { feat_id },
808        Some("SNEAK_ATTACK_5D6") => DecodedFeat::SneakAttack5D6 { feat_id },
809        Some("SNEAK_ATTACK_6D6") => DecodedFeat::SneakAttack6D6 { feat_id },
810        // Uncanny dodge tiers.
811        Some("UNCANNY_DODGE_1") => DecodedFeat::UncannyDodge1 { feat_id },
812        Some("UNCANNY_DODGE_2") => DecodedFeat::UncannyDodge2 { feat_id },
813        // Toughness ladder.
814        Some("TOUGHNESS") => DecodedFeat::Toughness { feat_id },
815        Some("IMPROVED_TOUGHNESS") => DecodedFeat::ImprovedToughness { feat_id },
816        Some("MASTER_TOUGHNESS") => DecodedFeat::MasterToughness { feat_id },
817        // Conditioning ladder.
818        Some("CONDITIONING") => DecodedFeat::Conditioning { feat_id },
819        Some("IMPROVED_CONDITIONING") => DecodedFeat::ImprovedConditioning { feat_id },
820        Some("MASTER_CONDITIONING") => DecodedFeat::MasterConditioning { feat_id },
821        // Implant slot ladder.
822        Some("IMPLANT_LEVEL_1") => DecodedFeat::ImplantLevel1 { feat_id },
823        Some("IMPLANT_LEVEL_2") => DecodedFeat::ImplantLevel2 { feat_id },
824        Some("IMPLANT_LEVEL_3") => DecodedFeat::ImplantLevel3 { feat_id },
825        // Droid combat / logic upgrades.
826        Some("DROID_UPGRADE_1") => DecodedFeat::DroidUpgrade1 { feat_id },
827        Some("DROID_UPGRADE_2") => DecodedFeat::DroidUpgrade2 { feat_id },
828        Some("DROID_UPGRADE_3") => DecodedFeat::DroidUpgrade3 { feat_id },
829        Some("BLASTER_INTEGRATION") => DecodedFeat::BlasterIntegration { feat_id },
830        Some("LOGIC_UPGRADE_COMBAT") => DecodedFeat::LogicUpgradeCombat { feat_id },
831        Some("LOGIC_UPGRADE_TACTICIAN") => DecodedFeat::LogicUpgradeTactician { feat_id },
832        // Dueling ladder.
833        Some("DUELING") => DecodedFeat::Dueling { feat_id },
834        Some("ADVANCED_DUELING") => DecodedFeat::AdvancedDueling { feat_id },
835        Some("MASTER_DUELING") => DecodedFeat::MasterDueling { feat_id },
836        // Sense ladder.
837        Some("JEDI_SENSE") => DecodedFeat::JediSense { feat_id },
838        Some("KNIGHT_SENSE") => DecodedFeat::KnightSense { feat_id },
839        Some("MASTER_SENSE") => DecodedFeat::MasterSense { feat_id },
840        // Deprecated guard stance ladder still wired into vanilla UTCs.
841        Some("XXXX_GUARD_STANCE") => DecodedFeat::GuardStance { feat_id },
842        Some("XXXX_ADVANCED_GUARD_STANCE") => DecodedFeat::AdvancedGuardStance { feat_id },
843        Some("XXXX_MASTER_GUARD_STANCE") => DecodedFeat::MasterGuardStance { feat_id },
844        // Deprecated skill focus feats still wired into vanilla UTCs.
845        Some("XXXX_SKILL_FOCUS_AWARENESS") => DecodedFeat::SkillFocusAwareness { feat_id },
846        Some("XXXX_SKILL_FOCUS_TREAT_INJURY") => DecodedFeat::SkillFocusTreatInjury { feat_id },
847        Some("XXXX_SKILL_FOCUS_STEALTH") => DecodedFeat::SkillFocusStealth { feat_id },
848        // Misc singletons (encounter-specific bonuses, racial feats,
849        // and one or two deprecated rows still loaded by vanilla UTCs).
850        Some("CAUTIOUS") => DecodedFeat::Cautious { feat_id },
851        Some("XXXX_PERCEPTIVE") => DecodedFeat::Perceptive { feat_id },
852        Some("EMPATHY") => DecodedFeat::Empathy { feat_id },
853        Some("GEAR_HEAD") => DecodedFeat::GearHead { feat_id },
854        Some("WOOKIE_ENDURANCE") => DecodedFeat::WookieEndurance { feat_id },
855        Some("SCOUNDRELS_LUCK") => DecodedFeat::ScoundrelsLuck { feat_id },
856        // Anything else (mod-added labels, vanilla rows the corpus
857        // never assigns, or absent feat.2da) falls through to
858        // Unknown carrying its resolved label when reachable.
859        _ => DecodedFeat::Unknown {
860            feat_id,
861            feat_label: label,
862        },
863    }
864}
865
866/// File-native projection of a [`Utc`].
867///
868/// Wraps a reference to the source UTC. Unlike
869/// [`UtiProjection`](super::uti::UtiProjection), UTC has no single
870/// dispatch table that must resolve at projection time; the typed
871/// list dispatch (feats, classes, spells) happens at snapshot time
872/// against their respective 2DAs. The projection still exists as the
873/// stable handle that one or more [`UtcSnapshot`]s build from, which
874/// matters for cross-scope analysis (mod conflict, vanilla vs modded,
875/// save / module VFS scoping).
876#[derive(Debug, Clone)]
877pub struct UtcProjection<'a> {
878    utc: &'a Utc,
879}
880
881/// Snapshot view over a [`Utc`], resolved against a per-scope context.
882///
883/// Built by [`UtcProjection::snapshot`] (or [`Utc::snapshot`] as a
884/// single-scope shortcut). Carries the source [`Utc`] plus cached
885/// scalar-id resolutions (race, appearance, portrait, soundset) and
886/// the typed-variant projection of `ClassList`.
887///
888/// All query methods take `&self` and read from the cached snapshot.
889/// The snapshot does not retain the [`TwoDaCache`] borrow once
890/// constructed; to query under a different scope, build a fresh
891/// snapshot from the same projection.
892#[derive(Debug)]
893pub struct UtcSnapshot<'a> {
894    utc: &'a Utc,
895    race_info: Option<RaceInfo>,
896    appearance_info: Option<AppearanceInfo>,
897    portrait_info: Option<PortraitInfo>,
898    soundset_info: Option<SoundsetInfo>,
899    decoded_classes: Vec<DecodedClass>,
900    decoded_special_abilities: Vec<DecodedSpecialAbility>,
901    decoded_feats: Vec<DecodedFeat>,
902}
903
904/// One decoded class entry from a UTC's `ClassList`, with the typed
905/// variant chosen by matching the `class_id` row's `label` cell in
906/// `classes.2da`.
907///
908/// Vanilla K1 carries nine class kinds; each gets its own typed
909/// variant. Mod-added or unrecognized rows surface as
910/// [`DecodedClass::Unknown`] carrying the raw `class_id` and the
911/// resolved label (when present) so callers can still display
912/// something useful. Dispatch is by `label`, not by raw `class_id`,
913/// so a mod that re-numbers a vanilla class still routes to its
914/// typed variant.
915///
916/// Every variant carries `class_id` (the raw integer the GFF stored;
917/// preserved so callers can recover the source-side row identity
918/// even after dispatch), `level` (the entry's `class_level`), and
919/// `powers` (the per-entry `Vec<u16>` of force-power ids that
920/// resolve into `spells.2da`; left raw at this stage and typed in a
921/// later commit).
922#[derive(Debug, Clone, PartialEq, Eq)]
923pub enum DecodedClass {
924    /// Vanilla `classes.2da` row label `Soldier` (vanilla row 0).
925    Soldier {
926        /// Raw `class_id` (the GFF integer the dispatch resolved from).
927        class_id: i32,
928        /// Raw `class_level` (entry's contribution to the character's
929        /// total level).
930        level: i16,
931        /// Raw `Vec<u16>` of force-power ids indexing `spells.2da`.
932        powers: Vec<u16>,
933    },
934    /// Vanilla `classes.2da` row label `Scout` (vanilla row 1).
935    Scout {
936        /// Raw `class_id`.
937        class_id: i32,
938        /// Raw `class_level`.
939        level: i16,
940        /// Raw force-power ids.
941        powers: Vec<u16>,
942    },
943    /// Vanilla `classes.2da` row label `Scoundrel` (vanilla row 2).
944    Scoundrel {
945        /// Raw `class_id`.
946        class_id: i32,
947        /// Raw `class_level`.
948        level: i16,
949        /// Raw force-power ids.
950        powers: Vec<u16>,
951    },
952    /// Vanilla `classes.2da` row label `JediGuardian` (vanilla row 3).
953    JediGuardian {
954        /// Raw `class_id`.
955        class_id: i32,
956        /// Raw `class_level`.
957        level: i16,
958        /// Raw force-power ids.
959        powers: Vec<u16>,
960    },
961    /// Vanilla `classes.2da` row label `JediConsular` (vanilla row 4).
962    JediConsular {
963        /// Raw `class_id`.
964        class_id: i32,
965        /// Raw `class_level`.
966        level: i16,
967        /// Raw force-power ids.
968        powers: Vec<u16>,
969    },
970    /// Vanilla `classes.2da` row label `JediSentinel` (vanilla row 5).
971    JediSentinel {
972        /// Raw `class_id`.
973        class_id: i32,
974        /// Raw `class_level`.
975        level: i16,
976        /// Raw force-power ids.
977        powers: Vec<u16>,
978    },
979    /// Vanilla `classes.2da` row label `CombatDroid` (vanilla row 6).
980    CombatDroid {
981        /// Raw `class_id`.
982        class_id: i32,
983        /// Raw `class_level`.
984        level: i16,
985        /// Raw force-power ids.
986        powers: Vec<u16>,
987    },
988    /// Vanilla `classes.2da` row label `ExpertDroid` (vanilla row 7).
989    ExpertDroid {
990        /// Raw `class_id`.
991        class_id: i32,
992        /// Raw `class_level`.
993        level: i16,
994        /// Raw force-power ids.
995        powers: Vec<u16>,
996    },
997    /// Vanilla `classes.2da` row label `Minion` (vanilla row 8).
998    Minion {
999        /// Raw `class_id`.
1000        class_id: i32,
1001        /// Raw `class_level`.
1002        level: i16,
1003        /// Raw force-power ids.
1004        powers: Vec<u16>,
1005    },
1006    /// Class identity could not be matched to any typed variant.
1007    /// Carries the raw fields plus the resolved label when reachable
1008    /// so consumers can still display something useful for mod-added
1009    /// or out-of-range classes.
1010    Unknown {
1011        /// Raw `class_id` from the GFF.
1012        class_id: i32,
1013        /// Resolved `label` cell from `classes.2da` at row `class_id`,
1014        /// or `None` when the table is unavailable, the row does not
1015        /// exist, or the label cell is missing.
1016        class_label: Option<String>,
1017        /// Raw `class_level`.
1018        level: i16,
1019        /// Raw force-power ids.
1020        powers: Vec<u16>,
1021    },
1022}
1023
1024impl DecodedClass {
1025    /// Returns the raw `class_id` the dispatch resolved from,
1026    /// regardless of variant.
1027    pub fn class_id(&self) -> i32 {
1028        match self {
1029            Self::Soldier { class_id, .. }
1030            | Self::Scout { class_id, .. }
1031            | Self::Scoundrel { class_id, .. }
1032            | Self::JediGuardian { class_id, .. }
1033            | Self::JediConsular { class_id, .. }
1034            | Self::JediSentinel { class_id, .. }
1035            | Self::CombatDroid { class_id, .. }
1036            | Self::ExpertDroid { class_id, .. }
1037            | Self::Minion { class_id, .. }
1038            | Self::Unknown { class_id, .. } => *class_id,
1039        }
1040    }
1041
1042    /// Returns the entry's `class_level` regardless of variant.
1043    pub fn level(&self) -> i16 {
1044        match self {
1045            Self::Soldier { level, .. }
1046            | Self::Scout { level, .. }
1047            | Self::Scoundrel { level, .. }
1048            | Self::JediGuardian { level, .. }
1049            | Self::JediConsular { level, .. }
1050            | Self::JediSentinel { level, .. }
1051            | Self::CombatDroid { level, .. }
1052            | Self::ExpertDroid { level, .. }
1053            | Self::Minion { level, .. }
1054            | Self::Unknown { level, .. } => *level,
1055        }
1056    }
1057
1058    /// Returns the entry's raw force-power id slice regardless of
1059    /// variant.
1060    pub fn powers(&self) -> &[u16] {
1061        match self {
1062            Self::Soldier { powers, .. }
1063            | Self::Scout { powers, .. }
1064            | Self::Scoundrel { powers, .. }
1065            | Self::JediGuardian { powers, .. }
1066            | Self::JediConsular { powers, .. }
1067            | Self::JediSentinel { powers, .. }
1068            | Self::CombatDroid { powers, .. }
1069            | Self::ExpertDroid { powers, .. }
1070            | Self::Minion { powers, .. }
1071            | Self::Unknown { powers, .. } => powers,
1072        }
1073    }
1074}
1075
1076/// Cached subset of a `racialtypes.2da` row, taken once at snapshot
1077/// time so [`UtcSnapshot::race_label`] can answer without holding a
1078/// 2DA cache borrow.
1079#[derive(Debug, Clone, Default)]
1080struct RaceInfo {
1081    label: Option<String>,
1082}
1083
1084/// Cached subset of an `appearance.2da` row.
1085#[derive(Debug, Clone, Default)]
1086struct AppearanceInfo {
1087    label: Option<String>,
1088}
1089
1090/// Cached subset of a `portraits.2da` row.
1091#[derive(Debug, Clone, Default)]
1092struct PortraitInfo {
1093    label: Option<String>,
1094}
1095
1096/// Cached subset of a `soundset.2da` row.
1097#[derive(Debug, Clone, Default)]
1098struct SoundsetInfo {
1099    label: Option<String>,
1100}
1101
1102impl RaceInfo {
1103    fn from_row(table: &TwoDa, race_id: u8) -> Option<Self> {
1104        let row = usize::from(race_id);
1105        if row >= table.rows.len() {
1106            return None;
1107        }
1108        Some(Self {
1109            label: table.cell(row, "label").map(str::to_string),
1110        })
1111    }
1112}
1113
1114impl AppearanceInfo {
1115    fn from_row(table: &TwoDa, appearance_id: u16) -> Option<Self> {
1116        let row = usize::from(appearance_id);
1117        if row >= table.rows.len() {
1118            return None;
1119        }
1120        Some(Self {
1121            label: table.cell(row, "label").map(str::to_string),
1122        })
1123    }
1124}
1125
1126impl PortraitInfo {
1127    fn from_row(table: &TwoDa, portrait_id: u16) -> Option<Self> {
1128        let row = usize::from(portrait_id);
1129        if row >= table.rows.len() {
1130            return None;
1131        }
1132        Some(Self {
1133            label: table.cell(row, "label").map(str::to_string),
1134        })
1135    }
1136}
1137
1138impl SoundsetInfo {
1139    fn from_row(table: &TwoDa, soundset_id: u16) -> Option<Self> {
1140        let row = usize::from(soundset_id);
1141        if row >= table.rows.len() {
1142            return None;
1143        }
1144        Some(Self {
1145            label: table.cell(row, "label").map(str::to_string),
1146        })
1147    }
1148}
1149
1150/// One decoded entry from a UTC's `SpecAbilityList`, dispatched by
1151/// matching the `spells.2da` row's `label` cell against the small
1152/// set of vanilla special-ability ids the corpus references.
1153///
1154/// Vanilla K1 references exactly three rows from special-ability
1155/// entries (`SPECIAL_ABILITY_BODY_FUEL`, `SPECIAL_ABILITY_RAGE`,
1156/// `MONSTER_ABILITY_SLAM_ATTACK`); each gets a typed variant. The
1157/// `partymember.utc` template references a spell id that does not
1158/// resolve in vanilla `spells.2da` (see the "Vanilla Data Anomalies"
1159/// subsection of the UTC engine audit), and that falls into
1160/// [`DecodedSpecialAbility::Unknown`] alongside any mod-added rows.
1161///
1162/// Every variant carries `spell_id` (preserved so callers can
1163/// recover the source row id even after dispatch), `flags`
1164/// (`SpellFlags`), and `caster_level` (`SpellCasterLevel`). The
1165/// engine appends every list entry without deduplication, so a UTC
1166/// carrying N entries with the same `spell_id` surfaces as N
1167/// separate `DecodedSpecialAbility` values.
1168#[derive(Debug, Clone, PartialEq, Eq)]
1169pub enum DecodedSpecialAbility {
1170    /// `spells.2da` label `SPECIAL_ABILITY_BODY_FUEL`. Stackable in
1171    /// vanilla -- the six Bastila variants each carry 99 entries.
1172    BodyFuel {
1173        /// Raw `spell_id`.
1174        spell_id: u16,
1175        /// Raw `SpellFlags`.
1176        flags: u8,
1177        /// Raw `SpellCasterLevel`.
1178        caster_level: u8,
1179    },
1180    /// `spells.2da` label `SPECIAL_ABILITY_RAGE`. Wookiee racial.
1181    Rage {
1182        /// Raw `spell_id`.
1183        spell_id: u16,
1184        /// Raw `SpellFlags`.
1185        flags: u8,
1186        /// Raw `SpellCasterLevel`.
1187        caster_level: u8,
1188    },
1189    /// `spells.2da` label `MONSTER_ABILITY_SLAM_ATTACK`. Monster
1190    /// classes (Wraids, Katarn, Korriban monsters).
1191    MonsterSlamAttack {
1192        /// Raw `spell_id`.
1193        spell_id: u16,
1194        /// Raw `SpellFlags`.
1195        flags: u8,
1196        /// Raw `SpellCasterLevel`.
1197        caster_level: u8,
1198    },
1199    /// Special ability could not be matched to a typed variant.
1200    /// Covers mod-added rows, vanilla anomalies (the `partymember`
1201    /// out-of-range `Spell = 299`), missing `spells.2da`, or any
1202    /// vanilla label not in the typed set.
1203    Unknown {
1204        /// Raw `spell_id` from the GFF.
1205        spell_id: u16,
1206        /// Resolved `label` cell from `spells.2da` at row `spell_id`,
1207        /// or `None` when the table is unavailable, the row does not
1208        /// exist, or the label cell is missing.
1209        spell_label: Option<String>,
1210        /// Raw `SpellFlags`.
1211        flags: u8,
1212        /// Raw `SpellCasterLevel`.
1213        caster_level: u8,
1214    },
1215}
1216
1217impl DecodedSpecialAbility {
1218    /// Returns the raw `spell_id` regardless of variant.
1219    pub fn spell_id(&self) -> u16 {
1220        match self {
1221            Self::BodyFuel { spell_id, .. }
1222            | Self::Rage { spell_id, .. }
1223            | Self::MonsterSlamAttack { spell_id, .. }
1224            | Self::Unknown { spell_id, .. } => *spell_id,
1225        }
1226    }
1227
1228    /// Returns `SpellFlags` regardless of variant.
1229    pub fn flags(&self) -> u8 {
1230        match self {
1231            Self::BodyFuel { flags, .. }
1232            | Self::Rage { flags, .. }
1233            | Self::MonsterSlamAttack { flags, .. }
1234            | Self::Unknown { flags, .. } => *flags,
1235        }
1236    }
1237
1238    /// Returns `SpellCasterLevel` regardless of variant.
1239    pub fn caster_level(&self) -> u8 {
1240        match self {
1241            Self::BodyFuel { caster_level, .. }
1242            | Self::Rage { caster_level, .. }
1243            | Self::MonsterSlamAttack { caster_level, .. }
1244            | Self::Unknown { caster_level, .. } => *caster_level,
1245        }
1246    }
1247}
1248
1249/// Resolves a `spells.2da` row label for a UTC special-ability entry,
1250/// then dispatches into the typed [`DecodedSpecialAbility`] variant
1251/// or `Unknown`. Dispatch is by `label` rather than by raw `spell_id`
1252/// so a mod that re-numbers a vanilla ability still routes correctly.
1253fn dispatch_special_ability(
1254    ability: &UtcSpecialAbility,
1255    spells_table: Option<&TwoDa>,
1256) -> DecodedSpecialAbility {
1257    let label = spells_table.and_then(|table| {
1258        let row = usize::from(ability.spell_id);
1259        if row >= table.rows.len() {
1260            return None;
1261        }
1262        table.cell(row, "label").map(str::to_string)
1263    });
1264    let spell_id = ability.spell_id;
1265    let flags = ability.spell_flags;
1266    let caster_level = ability.spell_caster_level;
1267    match label.as_deref() {
1268        Some("SPECIAL_ABILITY_BODY_FUEL") => DecodedSpecialAbility::BodyFuel {
1269            spell_id,
1270            flags,
1271            caster_level,
1272        },
1273        Some("SPECIAL_ABILITY_RAGE") => DecodedSpecialAbility::Rage {
1274            spell_id,
1275            flags,
1276            caster_level,
1277        },
1278        Some("MONSTER_ABILITY_SLAM_ATTACK") => DecodedSpecialAbility::MonsterSlamAttack {
1279            spell_id,
1280            flags,
1281            caster_level,
1282        },
1283        _ => DecodedSpecialAbility::Unknown {
1284            spell_id,
1285            spell_label: label,
1286            flags,
1287            caster_level,
1288        },
1289    }
1290}
1291
1292/// Resolves a `classes.2da` row label for a UTC class entry, then
1293/// dispatches into the typed [`DecodedClass`] variant or `Unknown`.
1294///
1295/// Dispatch is by `label`, not by raw `class_id`, so a mod that
1296/// re-numbers a vanilla class still routes correctly. A missing
1297/// `classes.2da`, an out-of-range `class_id`, an absent `label` cell,
1298/// or a label unknown to the dispatch table all surface as
1299/// [`DecodedClass::Unknown`] carrying whichever raw fields and
1300/// resolved label could be recovered.
1301fn dispatch_class(class: &UtcClass, classes_table: Option<&TwoDa>) -> DecodedClass {
1302    let label = classes_table.and_then(|table| {
1303        let row = usize::try_from(class.class_id).ok()?;
1304        if row >= table.rows.len() {
1305            return None;
1306        }
1307        table.cell(row, "label").map(str::to_string)
1308    });
1309    let class_id = class.class_id;
1310    let level = class.class_level;
1311    let powers = class.powers.clone();
1312    match label.as_deref() {
1313        Some("Soldier") => DecodedClass::Soldier {
1314            class_id,
1315            level,
1316            powers,
1317        },
1318        Some("Scout") => DecodedClass::Scout {
1319            class_id,
1320            level,
1321            powers,
1322        },
1323        Some("Scoundrel") => DecodedClass::Scoundrel {
1324            class_id,
1325            level,
1326            powers,
1327        },
1328        Some("JediGuardian") => DecodedClass::JediGuardian {
1329            class_id,
1330            level,
1331            powers,
1332        },
1333        Some("JediConsular") => DecodedClass::JediConsular {
1334            class_id,
1335            level,
1336            powers,
1337        },
1338        Some("JediSentinel") => DecodedClass::JediSentinel {
1339            class_id,
1340            level,
1341            powers,
1342        },
1343        Some("CombatDroid") => DecodedClass::CombatDroid {
1344            class_id,
1345            level,
1346            powers,
1347        },
1348        Some("ExpertDroid") => DecodedClass::ExpertDroid {
1349            class_id,
1350            level,
1351            powers,
1352        },
1353        Some("Minion") => DecodedClass::Minion {
1354            class_id,
1355            level,
1356            powers,
1357        },
1358        // Anything else: mod-extended row, unrecognized vanilla
1359        // label, missing 2DA, out-of-range id. Preserve raw fields
1360        // plus the resolved label (when there is one) so consumers
1361        // can still display something useful.
1362        _ => DecodedClass::Unknown {
1363            class_id,
1364            class_label: label,
1365            level,
1366            powers,
1367        },
1368    }
1369}
1370
1371impl<'a> UtcProjection<'a> {
1372    /// Returns a reference to the source UTC.
1373    pub fn as_utc(&self) -> &'a Utc {
1374        self.utc
1375    }
1376
1377    /// Resolves this projection against the given context, building a
1378    /// [`UtcSnapshot`].
1379    ///
1380    /// Loads `racialtypes.2da`, `appearance.2da`, `portraits.2da`,
1381    /// `soundset.2da`, `classes.2da`, `spells.2da`, and `feat.2da`
1382    /// (each tolerated as missing) and caches the rows the UTC's id
1383    /// fields point at, plus the typed-variant dispatch of every
1384    /// `ClassList`, `SpecAbilityList`, and `FeatList` entry. Multiple
1385    /// snapshots can be built from one projection, each capturing its
1386    /// own per-scope resolved data. The projection itself is
1387    /// unchanged and remains reusable across calls.
1388    pub fn snapshot(&self, cache: &mut TwoDaCache) -> UtcSnapshot<'a> {
1389        let race_info = cache
1390            .twoda(tables::RACIALTYPES)
1391            .ok()
1392            .and_then(|table| RaceInfo::from_row(table, self.utc.race_id));
1393        let appearance_info = cache
1394            .twoda(tables::APPEARANCE)
1395            .ok()
1396            .and_then(|table| AppearanceInfo::from_row(table, self.utc.appearance_id));
1397        let portrait_info = cache
1398            .twoda(tables::PORTRAITS)
1399            .ok()
1400            .and_then(|table| PortraitInfo::from_row(table, self.utc.portrait_id));
1401        let soundset_info = cache
1402            .twoda("soundset")
1403            .ok()
1404            .and_then(|table| SoundsetInfo::from_row(table, self.utc.soundset_id));
1405        let decoded_classes = {
1406            let classes_table = cache.twoda(tables::CLASSES).ok();
1407            self.utc
1408                .classes
1409                .iter()
1410                .map(|class| dispatch_class(class, classes_table))
1411                .collect()
1412        };
1413        let decoded_special_abilities = {
1414            let spells_table = cache.twoda("spells").ok();
1415            self.utc
1416                .special_abilities
1417                .iter()
1418                .map(|ability| dispatch_special_ability(ability, spells_table))
1419                .collect()
1420        };
1421        let decoded_feats = {
1422            let feat_table = cache.twoda(tables::FEAT).ok();
1423            self.utc
1424                .feats
1425                .iter()
1426                .map(|feat_id| dispatch_feat(*feat_id, feat_table))
1427                .collect()
1428        };
1429        UtcSnapshot {
1430            utc: self.utc,
1431            race_info,
1432            appearance_info,
1433            portrait_info,
1434            soundset_info,
1435            decoded_classes,
1436            decoded_special_abilities,
1437            decoded_feats,
1438        }
1439    }
1440}
1441
1442impl<'a> UtcSnapshot<'a> {
1443    /// Returns a reference to the source UTC.
1444    pub fn as_utc(&self) -> &'a Utc {
1445        self.utc
1446    }
1447
1448    /// Returns the `label` cell from `racialtypes.2da` at the UTC's
1449    /// `race_id` row.
1450    ///
1451    /// Returns `None` when `racialtypes.2da` is unavailable, the
1452    /// `race_id` does not resolve to a row, or the cell is missing.
1453    pub fn race_label(&self) -> Option<&str> {
1454        self.race_info.as_ref()?.label.as_deref()
1455    }
1456
1457    /// Returns the `label` cell from `appearance.2da` at the UTC's
1458    /// `appearance_id` row.
1459    ///
1460    /// Returns `None` when `appearance.2da` is unavailable, the
1461    /// `appearance_id` does not resolve to a row, or the cell is
1462    /// missing.
1463    pub fn appearance_label(&self) -> Option<&str> {
1464        self.appearance_info.as_ref()?.label.as_deref()
1465    }
1466
1467    /// Returns the `label` cell from `portraits.2da` at the UTC's
1468    /// `portrait_id` row.
1469    ///
1470    /// Returns `None` when `portraits.2da` is unavailable, the
1471    /// `portrait_id` does not resolve to a row, or the cell is
1472    /// missing.
1473    pub fn portrait_label(&self) -> Option<&str> {
1474        self.portrait_info.as_ref()?.label.as_deref()
1475    }
1476
1477    /// Returns the `label` cell from `soundset.2da` at the UTC's
1478    /// `soundset_id` row.
1479    ///
1480    /// Returns `None` when `soundset.2da` is unavailable, the
1481    /// `soundset_id` does not resolve to a row, or the cell is
1482    /// missing.
1483    pub fn soundset_label(&self) -> Option<&str> {
1484        self.soundset_info.as_ref()?.label.as_deref()
1485    }
1486
1487    /// Returns the UTC's `alignment` (good / evil axis), clamped to
1488    /// the engine's hard maximum of `100`.
1489    ///
1490    /// The engine's UTC loader applies the same clamp on read (see
1491    /// `CSWSCreatureStats::ReadStatsFromGff` in the UTC engine audit),
1492    /// so any UTC carrying a raw value above `100` will display as
1493    /// `100` at runtime regardless of what the GFF says.
1494    pub fn alignment(&self) -> u8 {
1495        self.utc.alignment.min(100)
1496    }
1497
1498    /// Returns the typed-variant decoded class entries as a borrowed
1499    /// slice.
1500    ///
1501    /// The order matches the source [`Utc::classes`] ordering;
1502    /// callers iterate or filter the slice. Multi-class characters
1503    /// surface as multiple entries in source order; the engine load
1504    /// path caps at two distinct class types per UTC, but this slice
1505    /// faithfully reflects whatever the GFF carries.
1506    pub fn classes(&self) -> &[DecodedClass] {
1507        &self.decoded_classes
1508    }
1509
1510    /// Sums the `class_level` across every decoded class entry,
1511    /// regardless of typed variant or [`DecodedClass::Unknown`]
1512    /// status. Returns the character's effective total level.
1513    ///
1514    /// Computed in `i32` so accumulation of multiple class entries
1515    /// cannot overflow the `i16` per-entry level type.
1516    pub fn total_level(&self) -> i32 {
1517        self.decoded_classes
1518            .iter()
1519            .map(|class| i32::from(class.level()))
1520            .sum()
1521    }
1522
1523    /// Returns `true` when any decoded class entry matches the given
1524    /// [`ClassKindFilter`].
1525    ///
1526    /// `Unknown` class entries never match a typed-variant filter;
1527    /// callers wanting to inspect unknown class kinds walk
1528    /// [`Self::classes`] directly.
1529    pub fn has_class(&self, filter: ClassKindFilter) -> bool {
1530        self.decoded_classes
1531            .iter()
1532            .any(|class| filter.matches(class))
1533    }
1534
1535    /// Returns `true` when the character carries any Jedi class
1536    /// entry (`JediGuardian`, `JediConsular`, or `JediSentinel`).
1537    /// Convenience wrapper over
1538    /// `has_class(ClassKindFilter::AnyJedi)`.
1539    pub fn is_force_user(&self) -> bool {
1540        self.has_class(ClassKindFilter::AnyJedi)
1541    }
1542
1543    /// Returns `true` when the character carries any droid class
1544    /// entry (`CombatDroid` or `ExpertDroid`). Convenience wrapper
1545    /// over `has_class(ClassKindFilter::AnyDroid)`.
1546    pub fn is_droid(&self) -> bool {
1547        self.has_class(ClassKindFilter::AnyDroid)
1548    }
1549
1550    /// Returns the typed-variant decoded feat entries as a borrowed
1551    /// slice.
1552    ///
1553    /// The order matches the source [`Utc::feats`] ordering. The
1554    /// engine load path appends every entry without deduplication,
1555    /// so a UTC carrying duplicate `feat_id`s surfaces as N separate
1556    /// slots. Each entry carries the raw `feat_id` regardless of
1557    /// dispatch outcome; callers wanting the resolved label for an
1558    /// `Unknown` walk the slice directly.
1559    pub fn feats(&self) -> &[DecodedFeat] {
1560        &self.decoded_feats
1561    }
1562
1563    /// Returns `true` when any decoded feat entry matches the given
1564    /// [`FeatKindFilter`].
1565    ///
1566    /// `Unknown` feat entries never match a typed-variant filter;
1567    /// callers wanting to inspect unknown feat labels walk
1568    /// [`Self::feats`] directly.
1569    pub fn has_feat(&self, filter: FeatKindFilter) -> bool {
1570        self.decoded_feats.iter().any(|feat| filter.matches(feat))
1571    }
1572
1573    /// Returns the typed-variant decoded special-ability entries as
1574    /// a borrowed slice.
1575    ///
1576    /// The order matches the source [`Utc::special_abilities`]
1577    /// ordering. The engine load path appends every entry without
1578    /// deduplication (see the "Vanilla Data Anomalies" subsection of
1579    /// the UTC engine audit), so a UTC carrying duplicate entries of
1580    /// the same `spell_id` (e.g. the Bastila variants' 99 stacked
1581    /// `BodyFuel` entries) surfaces as N separate slots in this
1582    /// slice.
1583    pub fn special_abilities(&self) -> &[DecodedSpecialAbility] {
1584        &self.decoded_special_abilities
1585    }
1586
1587    /// Returns the source UTC's `Equip_ItemList` as a borrowed slice,
1588    /// exposing slot id + UTI resref + drop flag per entry.
1589    ///
1590    /// Resolution of each `resref` to a [`UtiSnapshot`] is a
1591    /// cross-resource walk (it requires a [`Resolver`] to fetch the
1592    /// referenced UTI's bytes) and lives in the cross-resource lint
1593    /// scope rather than in the UTC decoded view. This accessor
1594    /// exists so consumers can route equipment access through the
1595    /// snapshot for API consistency with the rest of the query
1596    /// surface; the data itself is the raw [`UtcEquipmentItem`]
1597    /// slice from the source UTC.
1598    ///
1599    /// [`UtiSnapshot`]: super::uti::UtiSnapshot
1600    /// [`Resolver`]: rakata_extract::Resolver
1601    pub fn equipment(&self) -> &[UtcEquipmentItem] {
1602        &self.utc.equipment
1603    }
1604
1605    /// Returns the source UTC's `ItemList` as a borrowed slice,
1606    /// exposing entry id + UTI resref + drop flag + grid position
1607    /// per entry.
1608    ///
1609    /// Same cross-resource caveat as [`Self::equipment`]: resolving
1610    /// each `resref` to a [`UtiSnapshot`] requires a [`Resolver`]
1611    /// and lives in cross-resource lint scope.
1612    ///
1613    /// [`UtiSnapshot`]: super::uti::UtiSnapshot
1614    /// [`Resolver`]: rakata_extract::Resolver
1615    pub fn inventory(&self) -> &[UtcInventoryItem] {
1616        &self.utc.inventory
1617    }
1618}
1619
1620/// Categorical filter for [`UtcSnapshot::has_class`].
1621///
1622/// Each named variant maps to the matching [`DecodedClass`] typed
1623/// variant. The `AnyJedi` and `AnyDroid` variants are family filters
1624/// that match any of their member classes; they exist because "is
1625/// this a Jedi?" and "is this a droid?" are the two most-asked
1626/// high-level questions of a UTC's class identity.
1627///
1628/// [`DecodedClass::Unknown`] entries never match any filter. Callers
1629/// wanting to inspect mod-added classes walk [`UtcSnapshot::classes`]
1630/// directly.
1631#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1632pub enum ClassKindFilter {
1633    /// Matches [`DecodedClass::Soldier`].
1634    Soldier,
1635    /// Matches [`DecodedClass::Scout`].
1636    Scout,
1637    /// Matches [`DecodedClass::Scoundrel`].
1638    Scoundrel,
1639    /// Matches [`DecodedClass::JediGuardian`].
1640    JediGuardian,
1641    /// Matches [`DecodedClass::JediConsular`].
1642    JediConsular,
1643    /// Matches [`DecodedClass::JediSentinel`].
1644    JediSentinel,
1645    /// Matches [`DecodedClass::CombatDroid`].
1646    CombatDroid,
1647    /// Matches [`DecodedClass::ExpertDroid`].
1648    ExpertDroid,
1649    /// Matches [`DecodedClass::Minion`].
1650    Minion,
1651    /// Matches any of `JediGuardian`, `JediConsular`, `JediSentinel`.
1652    AnyJedi,
1653    /// Matches any of `CombatDroid`, `ExpertDroid`.
1654    AnyDroid,
1655}
1656
1657impl ClassKindFilter {
1658    fn matches(self, class: &DecodedClass) -> bool {
1659        match self {
1660            Self::Soldier => matches!(class, DecodedClass::Soldier { .. }),
1661            Self::Scout => matches!(class, DecodedClass::Scout { .. }),
1662            Self::Scoundrel => matches!(class, DecodedClass::Scoundrel { .. }),
1663            Self::JediGuardian => matches!(class, DecodedClass::JediGuardian { .. }),
1664            Self::JediConsular => matches!(class, DecodedClass::JediConsular { .. }),
1665            Self::JediSentinel => matches!(class, DecodedClass::JediSentinel { .. }),
1666            Self::CombatDroid => matches!(class, DecodedClass::CombatDroid { .. }),
1667            Self::ExpertDroid => matches!(class, DecodedClass::ExpertDroid { .. }),
1668            Self::Minion => matches!(class, DecodedClass::Minion { .. }),
1669            Self::AnyJedi => matches!(
1670                class,
1671                DecodedClass::JediGuardian { .. }
1672                    | DecodedClass::JediConsular { .. }
1673                    | DecodedClass::JediSentinel { .. }
1674            ),
1675            Self::AnyDroid => matches!(
1676                class,
1677                DecodedClass::CombatDroid { .. } | DecodedClass::ExpertDroid { .. }
1678            ),
1679        }
1680    }
1681}
1682
1683/// Family / aggregation filter for [`UtcSnapshot::has_feat`].
1684///
1685/// Each variant matches a ladder, family, or roll-up of typed
1686/// [`DecodedFeat`] variants. There's no 1:1 named filter per typed
1687/// feat because the vanilla feat set is too large for that to be
1688/// useful; callers wanting an exact-variant test should match on
1689/// [`DecodedFeat`] directly with `matches!`.
1690///
1691/// [`DecodedFeat::Unknown`] entries never match any filter. Callers
1692/// wanting to inspect mod-added feat labels walk [`UtcSnapshot::feats`]
1693/// directly.
1694#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1695pub enum FeatKindFilter {
1696    /// Any `ArmourProf*` variant.
1697    AnyArmourProficiency,
1698    /// Any `WeaponProf*` variant (including the deprecated `XXXX_`
1699    /// rows still loaded by vanilla UTCs) plus `ProficiencyAll`.
1700    AnyWeaponProficiency,
1701    /// Any `WeaponFocus*` variant.
1702    AnyWeaponFocus,
1703    /// Any `WeaponSpec*` variant.
1704    AnyWeaponSpecialization,
1705    /// `PowerAttack`, `ImprovedPowerAttack`, or `MasterPowerAttack`.
1706    AnyPowerAttack,
1707    /// `PowerBlast`, `ImprovedPowerBlast`, or `MasterPowerBlast`.
1708    AnyPowerBlast,
1709    /// `CriticalStrike`, `ImprovedCriticalStrike`, or
1710    /// `MasterCriticalStrike`.
1711    AnyCriticalStrike,
1712    /// `Flurry` or `ImprovedFlurry`.
1713    AnyFlurry,
1714    /// `SniperShot`, `ImprovedSniperShot`, or `MasterSniperShot`.
1715    AnySniperShot,
1716    /// `RapidShot` or `ImprovedRapidShot`.
1717    AnyRapidShot,
1718    /// `TwoWeaponFighting`, `TwoWeaponAdvanced`, or `TwoWeaponMastery`.
1719    AnyTwoWeapon,
1720    /// `JediDefense`, `AdvancedJediDefense`, or `MasterJediDefense`.
1721    AnyJediDefense,
1722    /// Any `ForceFocus*` variant (sense / alter / control schools).
1723    AnyForceFocus,
1724    /// `ForceJump`, `ForceJumpAdvanced`, or `ForceJumpMastery`.
1725    AnyForceJump,
1726    /// Any `ForceImmunity*` variant (fear / stun / paralysis).
1727    AnyForceImmunity,
1728    /// Any `SneakAttack*` variant (1D6 through 6D6).
1729    AnySneakAttack,
1730    /// `UncannyDodge1` or `UncannyDodge2`.
1731    AnyUncannyDodge,
1732    /// `Toughness`, `ImprovedToughness`, or `MasterToughness`.
1733    AnyToughness,
1734    /// `Conditioning`, `ImprovedConditioning`, or `MasterConditioning`.
1735    AnyConditioning,
1736    /// `ImplantLevel1`, `ImplantLevel2`, or `ImplantLevel3`.
1737    AnyImplant,
1738    /// `DroidUpgrade1`, `DroidUpgrade2`, or `DroidUpgrade3`.
1739    AnyDroidUpgrade,
1740    /// `Dueling`, `AdvancedDueling`, or `MasterDueling`.
1741    AnyDueling,
1742    /// `JediSense`, `KnightSense`, or `MasterSense`.
1743    AnySense,
1744    /// Any `*GuardStance` variant (deprecated by Bioware but still
1745    /// wired into vanilla UTCs).
1746    AnyGuardStance,
1747    /// Any `SkillFocus*` variant (deprecated by Bioware but still
1748    /// wired into a handful of vanilla UTCs).
1749    AnySkillFocus,
1750}
1751
1752impl FeatKindFilter {
1753    fn matches(self, feat: &DecodedFeat) -> bool {
1754        match self {
1755            Self::AnyArmourProficiency => matches!(
1756                feat,
1757                DecodedFeat::ArmourProfLight { .. }
1758                    | DecodedFeat::ArmourProfMedium { .. }
1759                    | DecodedFeat::ArmourProfHeavy { .. }
1760            ),
1761            Self::AnyWeaponProficiency => matches!(
1762                feat,
1763                DecodedFeat::WeaponProfBlaster { .. }
1764                    | DecodedFeat::WeaponProfBlasterRifle { .. }
1765                    | DecodedFeat::WeaponProfHeavyWeapons { .. }
1766                    | DecodedFeat::WeaponProfMeleeWeapons { .. }
1767                    | DecodedFeat::WeaponProfLightsaber { .. }
1768                    | DecodedFeat::WeaponProfGrenade { .. }
1769                    | DecodedFeat::WeaponProfSimpleWeapons { .. }
1770                    | DecodedFeat::ProficiencyAll { .. }
1771            ),
1772            Self::AnyWeaponFocus => matches!(
1773                feat,
1774                DecodedFeat::WeaponFocusBlaster { .. }
1775                    | DecodedFeat::WeaponFocusBlasterRifle { .. }
1776                    | DecodedFeat::WeaponFocusGrenade { .. }
1777                    | DecodedFeat::WeaponFocusHeavyWeapons { .. }
1778                    | DecodedFeat::WeaponFocusLightsaber { .. }
1779                    | DecodedFeat::WeaponFocusMeleeWeapons { .. }
1780                    | DecodedFeat::WeaponFocusSimpleWeapons { .. }
1781            ),
1782            Self::AnyWeaponSpecialization => matches!(
1783                feat,
1784                DecodedFeat::WeaponSpecBlaster { .. }
1785                    | DecodedFeat::WeaponSpecBlasterRifle { .. }
1786                    | DecodedFeat::WeaponSpecGrenade { .. }
1787                    | DecodedFeat::WeaponSpecHeavyWeapons { .. }
1788                    | DecodedFeat::WeaponSpecLightsaber { .. }
1789                    | DecodedFeat::WeaponSpecMeleeWeapons { .. }
1790                    | DecodedFeat::WeaponSpecSimpleWeapons { .. }
1791            ),
1792            Self::AnyPowerAttack => matches!(
1793                feat,
1794                DecodedFeat::PowerAttack { .. }
1795                    | DecodedFeat::ImprovedPowerAttack { .. }
1796                    | DecodedFeat::MasterPowerAttack { .. }
1797            ),
1798            Self::AnyPowerBlast => matches!(
1799                feat,
1800                DecodedFeat::PowerBlast { .. }
1801                    | DecodedFeat::ImprovedPowerBlast { .. }
1802                    | DecodedFeat::MasterPowerBlast { .. }
1803            ),
1804            Self::AnyCriticalStrike => matches!(
1805                feat,
1806                DecodedFeat::CriticalStrike { .. }
1807                    | DecodedFeat::ImprovedCriticalStrike { .. }
1808                    | DecodedFeat::MasterCriticalStrike { .. }
1809            ),
1810            Self::AnyFlurry => {
1811                matches!(
1812                    feat,
1813                    DecodedFeat::Flurry { .. } | DecodedFeat::ImprovedFlurry { .. }
1814                )
1815            }
1816            Self::AnySniperShot => matches!(
1817                feat,
1818                DecodedFeat::SniperShot { .. }
1819                    | DecodedFeat::ImprovedSniperShot { .. }
1820                    | DecodedFeat::MasterSniperShot { .. }
1821            ),
1822            Self::AnyRapidShot => matches!(
1823                feat,
1824                DecodedFeat::RapidShot { .. } | DecodedFeat::ImprovedRapidShot { .. }
1825            ),
1826            Self::AnyTwoWeapon => matches!(
1827                feat,
1828                DecodedFeat::TwoWeaponFighting { .. }
1829                    | DecodedFeat::TwoWeaponAdvanced { .. }
1830                    | DecodedFeat::TwoWeaponMastery { .. }
1831            ),
1832            Self::AnyJediDefense => matches!(
1833                feat,
1834                DecodedFeat::JediDefense { .. }
1835                    | DecodedFeat::AdvancedJediDefense { .. }
1836                    | DecodedFeat::MasterJediDefense { .. }
1837            ),
1838            Self::AnyForceFocus => matches!(
1839                feat,
1840                DecodedFeat::ForceFocus { .. }
1841                    | DecodedFeat::ForceFocusAdvanced { .. }
1842                    | DecodedFeat::ForceFocusMastery { .. }
1843                    | DecodedFeat::ForceFocusAlter { .. }
1844                    | DecodedFeat::ForceFocusControl { .. }
1845            ),
1846            Self::AnyForceJump => matches!(
1847                feat,
1848                DecodedFeat::ForceJump { .. }
1849                    | DecodedFeat::ForceJumpAdvanced { .. }
1850                    | DecodedFeat::ForceJumpMastery { .. }
1851            ),
1852            Self::AnyForceImmunity => matches!(
1853                feat,
1854                DecodedFeat::ForceImmunityFear { .. }
1855                    | DecodedFeat::ForceImmunityStun { .. }
1856                    | DecodedFeat::ForceImmunityParalysis { .. }
1857            ),
1858            Self::AnySneakAttack => matches!(
1859                feat,
1860                DecodedFeat::SneakAttack1D6 { .. }
1861                    | DecodedFeat::SneakAttack2D6 { .. }
1862                    | DecodedFeat::SneakAttack3D6 { .. }
1863                    | DecodedFeat::SneakAttack4D6 { .. }
1864                    | DecodedFeat::SneakAttack5D6 { .. }
1865                    | DecodedFeat::SneakAttack6D6 { .. }
1866            ),
1867            Self::AnyUncannyDodge => matches!(
1868                feat,
1869                DecodedFeat::UncannyDodge1 { .. } | DecodedFeat::UncannyDodge2 { .. }
1870            ),
1871            Self::AnyToughness => matches!(
1872                feat,
1873                DecodedFeat::Toughness { .. }
1874                    | DecodedFeat::ImprovedToughness { .. }
1875                    | DecodedFeat::MasterToughness { .. }
1876            ),
1877            Self::AnyConditioning => matches!(
1878                feat,
1879                DecodedFeat::Conditioning { .. }
1880                    | DecodedFeat::ImprovedConditioning { .. }
1881                    | DecodedFeat::MasterConditioning { .. }
1882            ),
1883            Self::AnyImplant => matches!(
1884                feat,
1885                DecodedFeat::ImplantLevel1 { .. }
1886                    | DecodedFeat::ImplantLevel2 { .. }
1887                    | DecodedFeat::ImplantLevel3 { .. }
1888            ),
1889            Self::AnyDroidUpgrade => matches!(
1890                feat,
1891                DecodedFeat::DroidUpgrade1 { .. }
1892                    | DecodedFeat::DroidUpgrade2 { .. }
1893                    | DecodedFeat::DroidUpgrade3 { .. }
1894            ),
1895            Self::AnyDueling => matches!(
1896                feat,
1897                DecodedFeat::Dueling { .. }
1898                    | DecodedFeat::AdvancedDueling { .. }
1899                    | DecodedFeat::MasterDueling { .. }
1900            ),
1901            Self::AnySense => matches!(
1902                feat,
1903                DecodedFeat::JediSense { .. }
1904                    | DecodedFeat::KnightSense { .. }
1905                    | DecodedFeat::MasterSense { .. }
1906            ),
1907            Self::AnyGuardStance => matches!(
1908                feat,
1909                DecodedFeat::GuardStance { .. }
1910                    | DecodedFeat::AdvancedGuardStance { .. }
1911                    | DecodedFeat::MasterGuardStance { .. }
1912            ),
1913            Self::AnySkillFocus => matches!(
1914                feat,
1915                DecodedFeat::SkillFocusAwareness { .. }
1916                    | DecodedFeat::SkillFocusTreatInjury { .. }
1917                    | DecodedFeat::SkillFocusStealth { .. }
1918            ),
1919        }
1920    }
1921}
1922
1923impl Utc {
1924    /// Builds a [`UtcProjection`] of this UTC.
1925    ///
1926    /// Unlike [`Uti::project`](crate::uti::Uti::project), UTC's
1927    /// projection takes no context: typed list dispatch happens at
1928    /// snapshot time against per-list 2DAs (`classes.2da`, `feat.2da`,
1929    /// `spells.2da`) rather than against one dispatch table at
1930    /// projection time. The projection stays cheap and scope-free,
1931    /// ready to feed one or more snapshots.
1932    pub fn project(&self) -> UtcProjection<'_> {
1933        UtcProjection { utc: self }
1934    }
1935
1936    /// Builds a [`UtcSnapshot`] of this UTC against the given 2DA
1937    /// cache. Single-scope shortcut equivalent to
1938    /// `self.project().snapshot(cache)`.
1939    ///
1940    /// For multi-scope workflows (mod conflict analysis, vanilla vs
1941    /// modded diffs), call [`Utc::project`] once and
1942    /// [`UtcProjection::snapshot`] per scope.
1943    pub fn snapshot(&self, cache: &mut TwoDaCache) -> UtcSnapshot<'_> {
1944        self.project().snapshot(cache)
1945    }
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950    use super::*;
1951    use rakata_core::{ResourceType, ResourceTypeCode};
1952    use rakata_extract::{OverrideSource, Resolver, ResolverSourceRef};
1953    use rakata_formats::twoda::{write_twoda_to_vec, TwoDaRow};
1954
1955    fn label_only_2da(rows: &[(usize, &str)]) -> TwoDa {
1956        let max_row = rows.iter().map(|(idx, _)| *idx).max().unwrap_or(0);
1957        let mut table_rows = Vec::with_capacity(max_row + 1);
1958        for row_index in 0..=max_row {
1959            let label = rows
1960                .iter()
1961                .find(|(idx, _)| *idx == row_index)
1962                .map(|(_, label)| (*label).to_string())
1963                .unwrap_or_default();
1964            table_rows.push(TwoDaRow {
1965                label: row_index.to_string(),
1966                cells: vec![label],
1967            });
1968        }
1969        TwoDa {
1970            headers: vec!["label".to_string()],
1971            rows: table_rows,
1972        }
1973    }
1974
1975    fn add_2da_entry(overrides: &mut OverrideSource, name: &str, table: &TwoDa) {
1976        let bytes = write_twoda_to_vec(table).expect("serialize 2da fixture");
1977        overrides
1978            .add_entry(
1979                name,
1980                ResourceTypeCode::from(ResourceType::TwoDa),
1981                bytes,
1982                "test",
1983            )
1984            .expect("add override entry");
1985    }
1986
1987    #[test]
1988    fn project_returns_projection_wrapping_source() {
1989        let utc = Utc::default();
1990        let projection = utc.project();
1991        assert!(std::ptr::eq(projection.as_utc(), &utc));
1992    }
1993
1994    #[test]
1995    fn snapshot_returns_snapshot_wrapping_source() {
1996        let utc = Utc::default();
1997        let resolver = Resolver::new();
1998        let mut cache = TwoDaCache::new(&resolver);
1999
2000        let snapshot = utc.snapshot(&mut cache);
2001        assert!(std::ptr::eq(snapshot.as_utc(), &utc));
2002    }
2003
2004    #[test]
2005    fn sugar_snapshot_matches_project_then_snapshot() {
2006        let utc = Utc::default();
2007        let resolver = Resolver::new();
2008
2009        let mut sugar_cache = TwoDaCache::new(&resolver);
2010        let sugar = utc.snapshot(&mut sugar_cache);
2011
2012        let mut explicit_cache = TwoDaCache::new(&resolver);
2013        let explicit = utc.project().snapshot(&mut explicit_cache);
2014
2015        assert!(std::ptr::eq(sugar.as_utc(), explicit.as_utc()));
2016    }
2017
2018    #[test]
2019    fn one_projection_feeds_independent_snapshots() {
2020        let utc = Utc::default();
2021        let resolver = Resolver::new();
2022        let projection = utc.project();
2023
2024        let mut cache_a = TwoDaCache::new(&resolver);
2025        let mut cache_b = TwoDaCache::new(&resolver);
2026
2027        let snap_a = projection.snapshot(&mut cache_a);
2028        let snap_b = projection.snapshot(&mut cache_b);
2029
2030        assert!(std::ptr::eq(snap_a.as_utc(), &utc));
2031        assert!(std::ptr::eq(snap_b.as_utc(), &utc));
2032    }
2033
2034    #[test]
2035    fn race_label_resolves_against_racialtypes_2da() {
2036        let utc = Utc {
2037            race_id: 6,
2038            ..Utc::default()
2039        };
2040        let racialtypes = label_only_2da(&[(6, "Human")]);
2041        let mut overrides = OverrideSource::new();
2042        add_2da_entry(&mut overrides, "racialtypes", &racialtypes);
2043        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2044        let mut cache = TwoDaCache::new(&resolver);
2045
2046        let snapshot = utc.snapshot(&mut cache);
2047        assert_eq!(snapshot.race_label(), Some("Human"));
2048    }
2049
2050    #[test]
2051    fn race_label_returns_none_when_race_id_is_out_of_range() {
2052        // racialtypes.2da has 2 rows, but utc.race_id = 99.
2053        let utc = Utc {
2054            race_id: 99,
2055            ..Utc::default()
2056        };
2057        let racialtypes = label_only_2da(&[(0, "Human"), (1, "Droid")]);
2058        let mut overrides = OverrideSource::new();
2059        add_2da_entry(&mut overrides, "racialtypes", &racialtypes);
2060        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2061        let mut cache = TwoDaCache::new(&resolver);
2062
2063        let snapshot = utc.snapshot(&mut cache);
2064        assert!(snapshot.race_label().is_none());
2065    }
2066
2067    #[test]
2068    fn race_label_returns_none_when_racialtypes_unavailable() {
2069        let utc = Utc {
2070            race_id: 6,
2071            ..Utc::default()
2072        };
2073        // Resolver knows nothing about racialtypes.2da.
2074        let resolver = Resolver::new();
2075        let mut cache = TwoDaCache::new(&resolver);
2076
2077        let snapshot = utc.snapshot(&mut cache);
2078        assert!(snapshot.race_label().is_none());
2079    }
2080
2081    #[test]
2082    fn appearance_portrait_soundset_labels_resolve_via_their_2das() {
2083        let utc = Utc {
2084            appearance_id: 12,
2085            portrait_id: 34,
2086            soundset_id: 56,
2087            ..Utc::default()
2088        };
2089        let appearance = label_only_2da(&[(12, "Male_Caucasian_Tough")]);
2090        let portraits = label_only_2da(&[(34, "po_pmhc01")]);
2091        let soundset = label_only_2da(&[(56, "n_default")]);
2092        let mut overrides = OverrideSource::new();
2093        add_2da_entry(&mut overrides, "appearance", &appearance);
2094        add_2da_entry(&mut overrides, "portraits", &portraits);
2095        add_2da_entry(&mut overrides, "soundset", &soundset);
2096        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2097        let mut cache = TwoDaCache::new(&resolver);
2098
2099        let snapshot = utc.snapshot(&mut cache);
2100        assert_eq!(snapshot.appearance_label(), Some("Male_Caucasian_Tough"));
2101        assert_eq!(snapshot.portrait_label(), Some("po_pmhc01"));
2102        assert_eq!(snapshot.soundset_label(), Some("n_default"));
2103    }
2104
2105    #[test]
2106    fn alignment_returns_raw_value_when_in_range() {
2107        let utc = Utc {
2108            alignment: 50,
2109            ..Utc::default()
2110        };
2111        let resolver = Resolver::new();
2112        let mut cache = TwoDaCache::new(&resolver);
2113
2114        let snapshot = utc.snapshot(&mut cache);
2115        assert_eq!(snapshot.alignment(), 50);
2116    }
2117
2118    #[test]
2119    fn alignment_clamps_at_one_hundred_to_match_engine_load_behavior() {
2120        // Engine's GoodEvil clamp ceiling is 100; a UTC carrying a
2121        // raw 200 displays as 100 at runtime regardless of what the
2122        // GFF says.
2123        let utc = Utc {
2124            alignment: 200,
2125            ..Utc::default()
2126        };
2127        let resolver = Resolver::new();
2128        let mut cache = TwoDaCache::new(&resolver);
2129
2130        let snapshot = utc.snapshot(&mut cache);
2131        assert_eq!(snapshot.alignment(), 100);
2132    }
2133
2134    // -- DecodedClass dispatch --
2135
2136    fn class_entry(class_id: i32, level: i16, powers: Vec<u16>) -> UtcClass {
2137        UtcClass {
2138            class_id,
2139            class_level: level,
2140            powers,
2141        }
2142    }
2143
2144    #[test]
2145    fn classes_dispatch_every_vanilla_label_to_its_typed_variant() {
2146        // Build a UTC carrying one entry per vanilla class id and a
2147        // classes.2da fixture whose labels match the vanilla layout.
2148        let utc = Utc {
2149            classes: vec![
2150                class_entry(0, 1, vec![]),
2151                class_entry(1, 1, vec![]),
2152                class_entry(2, 1, vec![]),
2153                class_entry(3, 1, vec![]),
2154                class_entry(4, 1, vec![]),
2155                class_entry(5, 1, vec![]),
2156                class_entry(6, 1, vec![]),
2157                class_entry(7, 1, vec![]),
2158                class_entry(8, 1, vec![]),
2159            ],
2160            ..Utc::default()
2161        };
2162        let classes = label_only_2da(&[
2163            (0, "Soldier"),
2164            (1, "Scout"),
2165            (2, "Scoundrel"),
2166            (3, "JediGuardian"),
2167            (4, "JediConsular"),
2168            (5, "JediSentinel"),
2169            (6, "CombatDroid"),
2170            (7, "ExpertDroid"),
2171            (8, "Minion"),
2172        ]);
2173        let mut overrides = OverrideSource::new();
2174        add_2da_entry(&mut overrides, "classes", &classes);
2175        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2176        let mut cache = TwoDaCache::new(&resolver);
2177
2178        let snapshot = utc.snapshot(&mut cache);
2179        let kinds: Vec<&str> = snapshot
2180            .classes()
2181            .iter()
2182            .map(|class| match class {
2183                DecodedClass::Soldier { .. } => "Soldier",
2184                DecodedClass::Scout { .. } => "Scout",
2185                DecodedClass::Scoundrel { .. } => "Scoundrel",
2186                DecodedClass::JediGuardian { .. } => "JediGuardian",
2187                DecodedClass::JediConsular { .. } => "JediConsular",
2188                DecodedClass::JediSentinel { .. } => "JediSentinel",
2189                DecodedClass::CombatDroid { .. } => "CombatDroid",
2190                DecodedClass::ExpertDroid { .. } => "ExpertDroid",
2191                DecodedClass::Minion { .. } => "Minion",
2192                DecodedClass::Unknown { .. } => "<unknown>",
2193            })
2194            .collect();
2195        assert_eq!(
2196            kinds,
2197            vec![
2198                "Soldier",
2199                "Scout",
2200                "Scoundrel",
2201                "JediGuardian",
2202                "JediConsular",
2203                "JediSentinel",
2204                "CombatDroid",
2205                "ExpertDroid",
2206                "Minion",
2207            ]
2208        );
2209    }
2210
2211    #[test]
2212    fn classes_dispatch_by_label_so_mod_renumbered_class_still_routes() {
2213        // Mod relocates JediGuardian to row 42 in classes.2da. The
2214        // UTC's class_id is 42; dispatch is by label, so it still
2215        // routes to DecodedClass::JediGuardian rather than Unknown.
2216        let utc = Utc {
2217            classes: vec![class_entry(42, 5, vec![])],
2218            ..Utc::default()
2219        };
2220        let classes = label_only_2da(&[(42, "JediGuardian")]);
2221        let mut overrides = OverrideSource::new();
2222        add_2da_entry(&mut overrides, "classes", &classes);
2223        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2224        let mut cache = TwoDaCache::new(&resolver);
2225
2226        let snapshot = utc.snapshot(&mut cache);
2227        match &snapshot.classes()[0] {
2228            DecodedClass::JediGuardian {
2229                class_id, level, ..
2230            } => {
2231                // class_id preserved from the source GFF, not from
2232                // any vanilla-default lookup.
2233                assert_eq!(*class_id, 42);
2234                assert_eq!(*level, 5);
2235            }
2236            other => panic!("expected JediGuardian, got {other:?}"),
2237        }
2238    }
2239
2240    #[test]
2241    fn unknown_class_carries_resolved_label_when_2da_row_exists() {
2242        // Mod adds row 9 with a label our dispatch does not know.
2243        // Result is Unknown with the resolved label surfaced.
2244        let utc = Utc {
2245            classes: vec![class_entry(9, 3, vec![])],
2246            ..Utc::default()
2247        };
2248        let classes = label_only_2da(&[(9, "DarkJediMaster")]);
2249        let mut overrides = OverrideSource::new();
2250        add_2da_entry(&mut overrides, "classes", &classes);
2251        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2252        let mut cache = TwoDaCache::new(&resolver);
2253
2254        let snapshot = utc.snapshot(&mut cache);
2255        match &snapshot.classes()[0] {
2256            DecodedClass::Unknown {
2257                class_id,
2258                class_label,
2259                level,
2260                ..
2261            } => {
2262                assert_eq!(*class_id, 9);
2263                assert_eq!(class_label.as_deref(), Some("DarkJediMaster"));
2264                assert_eq!(*level, 3);
2265            }
2266            other => panic!("expected Unknown, got {other:?}"),
2267        }
2268    }
2269
2270    #[test]
2271    fn unknown_class_carries_no_label_when_class_id_is_out_of_range() {
2272        // class_id 99 has no row in the fixture; label resolution
2273        // yields None.
2274        let utc = Utc {
2275            classes: vec![class_entry(99, 1, vec![])],
2276            ..Utc::default()
2277        };
2278        let classes = label_only_2da(&[(0, "Soldier")]);
2279        let mut overrides = OverrideSource::new();
2280        add_2da_entry(&mut overrides, "classes", &classes);
2281        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2282        let mut cache = TwoDaCache::new(&resolver);
2283
2284        let snapshot = utc.snapshot(&mut cache);
2285        match &snapshot.classes()[0] {
2286            DecodedClass::Unknown {
2287                class_id,
2288                class_label,
2289                ..
2290            } => {
2291                assert_eq!(*class_id, 99);
2292                assert!(class_label.is_none());
2293            }
2294            other => panic!("expected Unknown, got {other:?}"),
2295        }
2296    }
2297
2298    #[test]
2299    fn unknown_class_carries_no_label_when_classes_2da_unavailable() {
2300        // Resolver knows nothing about classes.2da; every entry
2301        // surfaces as Unknown with no label.
2302        let utc = Utc {
2303            classes: vec![class_entry(3, 5, vec![])],
2304            ..Utc::default()
2305        };
2306        let resolver = Resolver::new();
2307        let mut cache = TwoDaCache::new(&resolver);
2308
2309        let snapshot = utc.snapshot(&mut cache);
2310        match &snapshot.classes()[0] {
2311            DecodedClass::Unknown {
2312                class_id,
2313                class_label,
2314                level,
2315                ..
2316            } => {
2317                assert_eq!(*class_id, 3);
2318                assert!(class_label.is_none());
2319                assert_eq!(*level, 5);
2320            }
2321            other => panic!("expected Unknown, got {other:?}"),
2322        }
2323    }
2324
2325    #[test]
2326    fn class_powers_preserve_source_order_per_entry() {
2327        let utc = Utc {
2328            classes: vec![class_entry(4, 6, vec![100, 200, 50, 200])],
2329            ..Utc::default()
2330        };
2331        let classes = label_only_2da(&[(4, "JediConsular")]);
2332        let mut overrides = OverrideSource::new();
2333        add_2da_entry(&mut overrides, "classes", &classes);
2334        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2335        let mut cache = TwoDaCache::new(&resolver);
2336
2337        let snapshot = utc.snapshot(&mut cache);
2338        // Duplicates are preserved (vanilla data can stack the same
2339        // power id) and order matches the source Vec.
2340        assert_eq!(snapshot.classes()[0].powers(), &[100, 200, 50, 200]);
2341    }
2342
2343    #[test]
2344    fn total_level_sums_across_typed_and_unknown_entries() {
2345        // Multi-class character: typed JediGuardian (level 19) +
2346        // typed JediSentinel (level 3) + an Unknown mod-class (level
2347        // 4). Total is 19 + 3 + 4 = 26 regardless of dispatch outcome.
2348        let utc = Utc {
2349            classes: vec![
2350                class_entry(3, 19, vec![]),
2351                class_entry(5, 3, vec![]),
2352                class_entry(99, 4, vec![]),
2353            ],
2354            ..Utc::default()
2355        };
2356        let classes = label_only_2da(&[(0, ""), (3, "JediGuardian"), (5, "JediSentinel")]);
2357        let mut overrides = OverrideSource::new();
2358        add_2da_entry(&mut overrides, "classes", &classes);
2359        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2360        let mut cache = TwoDaCache::new(&resolver);
2361
2362        let snapshot = utc.snapshot(&mut cache);
2363        assert_eq!(snapshot.total_level(), 26);
2364    }
2365
2366    #[test]
2367    fn one_projection_yields_diverging_class_kinds_per_scope() {
2368        // Mod conflict scenario for class dispatch: same UTC,
2369        // class_id 3 labelled JediGuardian in vanilla but rebadged
2370        // as a custom name in the mod. The shared projection feeds
2371        // two snapshots; each reports its own dispatch outcome.
2372        let utc = Utc {
2373            classes: vec![class_entry(3, 10, vec![])],
2374            ..Utc::default()
2375        };
2376
2377        let vanilla_classes = label_only_2da(&[(3, "JediGuardian")]);
2378        let mut vanilla_overrides = OverrideSource::new();
2379        add_2da_entry(&mut vanilla_overrides, "classes", &vanilla_classes);
2380        let vanilla_resolver =
2381            Resolver::new().with_source(ResolverSourceRef::Override(&vanilla_overrides));
2382
2383        let mod_classes = label_only_2da(&[(3, "MandalorianWarrior")]);
2384        let mut mod_overrides = OverrideSource::new();
2385        add_2da_entry(&mut mod_overrides, "classes", &mod_classes);
2386        let mod_resolver = Resolver::new().with_source(ResolverSourceRef::Override(&mod_overrides));
2387
2388        let projection = utc.project();
2389        let mut vanilla_cache = TwoDaCache::new(&vanilla_resolver);
2390        let mut mod_cache = TwoDaCache::new(&mod_resolver);
2391
2392        let vanilla = projection.snapshot(&mut vanilla_cache);
2393        let modded = projection.snapshot(&mut mod_cache);
2394
2395        assert!(matches!(
2396            vanilla.classes()[0],
2397            DecodedClass::JediGuardian { .. }
2398        ));
2399        match &modded.classes()[0] {
2400            DecodedClass::Unknown { class_label, .. } => {
2401                assert_eq!(class_label.as_deref(), Some("MandalorianWarrior"));
2402            }
2403            other => panic!("expected Unknown, got {other:?}"),
2404        }
2405    }
2406
2407    // -- Equipment / inventory passthroughs --
2408
2409    #[test]
2410    fn equipment_and_inventory_passthrough_source_slices() {
2411        use rakata_core::ResRef;
2412
2413        let equip_resref = ResRef::new("g_w_blstrpstl001").expect("valid resref");
2414        let inv_resref = ResRef::new("g_i_medeqpmnt01").expect("valid resref");
2415        let utc = Utc {
2416            equipment: vec![UtcEquipmentItem::new(2, equip_resref)],
2417            inventory: vec![UtcInventoryItem::new(0, inv_resref)],
2418            ..Utc::default()
2419        };
2420        let resolver = Resolver::new();
2421        let mut cache = TwoDaCache::new(&resolver);
2422
2423        let snapshot = utc.snapshot(&mut cache);
2424        assert_eq!(snapshot.equipment().len(), 1);
2425        assert_eq!(snapshot.equipment()[0].slot_id, 2);
2426        assert_eq!(snapshot.equipment()[0].resref, equip_resref);
2427        assert_eq!(snapshot.inventory().len(), 1);
2428        assert_eq!(snapshot.inventory()[0].entry_id, 0);
2429        assert_eq!(snapshot.inventory()[0].resref, inv_resref);
2430    }
2431
2432    // -- DecodedSpecialAbility dispatch --
2433
2434    fn ability_entry(spell_id: u16, flags: u8, caster_level: u8) -> UtcSpecialAbility {
2435        UtcSpecialAbility {
2436            spell_id,
2437            spell_flags: flags,
2438            spell_caster_level: caster_level,
2439        }
2440    }
2441
2442    fn vanilla_spells_2da() -> TwoDa {
2443        // Vanilla rows the corpus actually references; the gaps in
2444        // between rows are populated with empty labels for fidelity.
2445        label_only_2da(&[
2446            (52, "SPECIAL_ABILITY_BODY_FUEL"),
2447            (63, "SPECIAL_ABILITY_RAGE"),
2448            (83, "MONSTER_ABILITY_SLAM_ATTACK"),
2449        ])
2450    }
2451
2452    #[test]
2453    fn special_abilities_dispatch_three_vanilla_labels_to_typed_variants() {
2454        let utc = Utc {
2455            special_abilities: vec![
2456                ability_entry(52, 0, 1),
2457                ability_entry(63, 0, 1),
2458                ability_entry(83, 0, 1),
2459            ],
2460            ..Utc::default()
2461        };
2462        let mut overrides = OverrideSource::new();
2463        add_2da_entry(&mut overrides, "spells", &vanilla_spells_2da());
2464        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2465        let mut cache = TwoDaCache::new(&resolver);
2466
2467        let snapshot = utc.snapshot(&mut cache);
2468        assert!(matches!(
2469            snapshot.special_abilities()[0],
2470            DecodedSpecialAbility::BodyFuel { .. }
2471        ));
2472        assert!(matches!(
2473            snapshot.special_abilities()[1],
2474            DecodedSpecialAbility::Rage { .. }
2475        ));
2476        assert!(matches!(
2477            snapshot.special_abilities()[2],
2478            DecodedSpecialAbility::MonsterSlamAttack { .. }
2479        ));
2480    }
2481
2482    #[test]
2483    fn special_abilities_preserve_stacked_duplicates() {
2484        // Mirrors the vanilla Bastila 99-stack pattern. The engine
2485        // loader appends every entry; the decoder must do the same.
2486        let utc = Utc {
2487            special_abilities: (0..5).map(|_| ability_entry(52, 0, 1)).collect(),
2488            ..Utc::default()
2489        };
2490        let mut overrides = OverrideSource::new();
2491        add_2da_entry(&mut overrides, "spells", &vanilla_spells_2da());
2492        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2493        let mut cache = TwoDaCache::new(&resolver);
2494
2495        let snapshot = utc.snapshot(&mut cache);
2496        assert_eq!(snapshot.special_abilities().len(), 5);
2497        for entry in snapshot.special_abilities() {
2498            assert!(matches!(entry, DecodedSpecialAbility::BodyFuel { .. }));
2499        }
2500    }
2501
2502    #[test]
2503    fn out_of_range_spell_id_surfaces_as_unknown_with_no_label() {
2504        // The partymember.utc anomaly: spell_id 299 vs 132-row
2505        // vanilla spells.2da. Must round-trip as Unknown carrying
2506        // the raw id and no label.
2507        let utc = Utc {
2508            special_abilities: vec![ability_entry(299, 1, 1)],
2509            ..Utc::default()
2510        };
2511        let mut overrides = OverrideSource::new();
2512        add_2da_entry(&mut overrides, "spells", &vanilla_spells_2da());
2513        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2514        let mut cache = TwoDaCache::new(&resolver);
2515
2516        let snapshot = utc.snapshot(&mut cache);
2517        match &snapshot.special_abilities()[0] {
2518            DecodedSpecialAbility::Unknown {
2519                spell_id,
2520                spell_label,
2521                flags,
2522                caster_level,
2523            } => {
2524                assert_eq!(*spell_id, 299);
2525                assert!(spell_label.is_none());
2526                assert_eq!(*flags, 1);
2527                assert_eq!(*caster_level, 1);
2528            }
2529            other => panic!("expected Unknown, got {other:?}"),
2530        }
2531    }
2532
2533    #[test]
2534    fn empty_special_ability_list_decodes_to_empty_slice() {
2535        let utc = Utc::default();
2536        let resolver = Resolver::new();
2537        let mut cache = TwoDaCache::new(&resolver);
2538
2539        let snapshot = utc.snapshot(&mut cache);
2540        assert!(snapshot.special_abilities().is_empty());
2541    }
2542
2543    // -- ClassKindFilter / high-level helpers --
2544
2545    fn utc_with_classes(class_ids: &[i32]) -> Utc {
2546        Utc {
2547            classes: class_ids
2548                .iter()
2549                .map(|id| class_entry(*id, 1, vec![]))
2550                .collect(),
2551            ..Utc::default()
2552        }
2553    }
2554
2555    fn vanilla_classes_2da() -> TwoDa {
2556        label_only_2da(&[
2557            (0, "Soldier"),
2558            (1, "Scout"),
2559            (2, "Scoundrel"),
2560            (3, "JediGuardian"),
2561            (4, "JediConsular"),
2562            (5, "JediSentinel"),
2563            (6, "CombatDroid"),
2564            (7, "ExpertDroid"),
2565            (8, "Minion"),
2566        ])
2567    }
2568
2569    fn vanilla_class_cache(overrides: &mut OverrideSource) {
2570        let classes = vanilla_classes_2da();
2571        add_2da_entry(overrides, "classes", &classes);
2572    }
2573
2574    #[test]
2575    fn is_force_user_matches_any_jedi_class() {
2576        for jedi_id in [3, 4, 5] {
2577            let utc = utc_with_classes(&[jedi_id]);
2578            let mut overrides = OverrideSource::new();
2579            vanilla_class_cache(&mut overrides);
2580            let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2581            let mut cache = TwoDaCache::new(&resolver);
2582
2583            let snapshot = utc.snapshot(&mut cache);
2584            assert!(
2585                snapshot.is_force_user(),
2586                "class_id {jedi_id} should be a force user"
2587            );
2588        }
2589    }
2590
2591    #[test]
2592    fn is_force_user_false_for_non_jedi_classes() {
2593        for non_jedi_id in [0, 1, 2, 6, 7, 8] {
2594            let utc = utc_with_classes(&[non_jedi_id]);
2595            let mut overrides = OverrideSource::new();
2596            vanilla_class_cache(&mut overrides);
2597            let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2598            let mut cache = TwoDaCache::new(&resolver);
2599
2600            let snapshot = utc.snapshot(&mut cache);
2601            assert!(
2602                !snapshot.is_force_user(),
2603                "class_id {non_jedi_id} should not be a force user"
2604            );
2605        }
2606    }
2607
2608    #[test]
2609    fn is_droid_matches_combat_and_expert_droid() {
2610        for droid_id in [6, 7] {
2611            let utc = utc_with_classes(&[droid_id]);
2612            let mut overrides = OverrideSource::new();
2613            vanilla_class_cache(&mut overrides);
2614            let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2615            let mut cache = TwoDaCache::new(&resolver);
2616
2617            let snapshot = utc.snapshot(&mut cache);
2618            assert!(snapshot.is_droid(), "class_id {droid_id} should be a droid");
2619        }
2620    }
2621
2622    #[test]
2623    fn is_droid_false_for_organic_classes() {
2624        for organic_id in [0, 1, 2, 3, 4, 5, 8] {
2625            let utc = utc_with_classes(&[organic_id]);
2626            let mut overrides = OverrideSource::new();
2627            vanilla_class_cache(&mut overrides);
2628            let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2629            let mut cache = TwoDaCache::new(&resolver);
2630
2631            let snapshot = utc.snapshot(&mut cache);
2632            assert!(
2633                !snapshot.is_droid(),
2634                "class_id {organic_id} should not be a droid"
2635            );
2636        }
2637    }
2638
2639    #[test]
2640    fn has_class_matches_specific_typed_variants() {
2641        let utc = utc_with_classes(&[0, 3]);
2642        let mut overrides = OverrideSource::new();
2643        vanilla_class_cache(&mut overrides);
2644        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2645        let mut cache = TwoDaCache::new(&resolver);
2646
2647        let snapshot = utc.snapshot(&mut cache);
2648        assert!(snapshot.has_class(ClassKindFilter::Soldier));
2649        assert!(snapshot.has_class(ClassKindFilter::JediGuardian));
2650        assert!(!snapshot.has_class(ClassKindFilter::Scout));
2651        assert!(!snapshot.has_class(ClassKindFilter::Minion));
2652    }
2653
2654    #[test]
2655    fn has_class_never_matches_unknown_entries() {
2656        // class_id 99 has no matching row; it surfaces as Unknown.
2657        // No typed-variant filter (or family filter) matches it.
2658        let utc = utc_with_classes(&[99]);
2659        let mut overrides = OverrideSource::new();
2660        vanilla_class_cache(&mut overrides);
2661        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2662        let mut cache = TwoDaCache::new(&resolver);
2663
2664        let snapshot = utc.snapshot(&mut cache);
2665        assert!(!snapshot.has_class(ClassKindFilter::Soldier));
2666        assert!(!snapshot.has_class(ClassKindFilter::AnyJedi));
2667        assert!(!snapshot.has_class(ClassKindFilter::AnyDroid));
2668        assert!(!snapshot.is_force_user());
2669        assert!(!snapshot.is_droid());
2670    }
2671
2672    #[test]
2673    fn empty_class_list_helpers_all_return_false() {
2674        let utc = Utc::default();
2675        let resolver = Resolver::new();
2676        let mut cache = TwoDaCache::new(&resolver);
2677
2678        let snapshot = utc.snapshot(&mut cache);
2679        assert!(!snapshot.is_force_user());
2680        assert!(!snapshot.is_droid());
2681        assert!(!snapshot.has_class(ClassKindFilter::JediGuardian));
2682    }
2683
2684    #[test]
2685    fn empty_class_list_decodes_to_empty_classes_slice() {
2686        let utc = Utc::default();
2687        let resolver = Resolver::new();
2688        let mut cache = TwoDaCache::new(&resolver);
2689
2690        let snapshot = utc.snapshot(&mut cache);
2691        assert!(snapshot.classes().is_empty());
2692        assert_eq!(snapshot.total_level(), 0);
2693    }
2694
2695    #[test]
2696    fn one_projection_yields_diverging_race_labels_per_scope() {
2697        // Mod conflict scenario: same UTC bytes, two scopes whose
2698        // racialtypes.2da labels differ at the same row index. The
2699        // shared projection feeds two snapshots; each reports its
2700        // scope's resolved label.
2701        let utc = Utc {
2702            race_id: 6,
2703            ..Utc::default()
2704        };
2705
2706        let vanilla_races = label_only_2da(&[(6, "Human")]);
2707        let mut vanilla_overrides = OverrideSource::new();
2708        add_2da_entry(&mut vanilla_overrides, "racialtypes", &vanilla_races);
2709        let vanilla_resolver =
2710            Resolver::new().with_source(ResolverSourceRef::Override(&vanilla_overrides));
2711
2712        let mod_races = label_only_2da(&[(6, "Mandalorian")]);
2713        let mut mod_overrides = OverrideSource::new();
2714        add_2da_entry(&mut mod_overrides, "racialtypes", &mod_races);
2715        let mod_resolver = Resolver::new().with_source(ResolverSourceRef::Override(&mod_overrides));
2716
2717        let projection = utc.project();
2718        let mut vanilla_cache = TwoDaCache::new(&vanilla_resolver);
2719        let mut mod_cache = TwoDaCache::new(&mod_resolver);
2720
2721        let vanilla = projection.snapshot(&mut vanilla_cache);
2722        let modded = projection.snapshot(&mut mod_cache);
2723
2724        assert_eq!(vanilla.race_label(), Some("Human"));
2725        assert_eq!(modded.race_label(), Some("Mandalorian"));
2726    }
2727
2728    // -- DecodedFeat dispatch --
2729
2730    fn weapon_feats_2da() -> TwoDa {
2731        label_only_2da(&[
2732            (4, "ARMOUR_PROF_HEAVY"),
2733            (5, "ARMOUR_PROF_LIGHT"),
2734            (6, "ARMOUR_PROF_MEDIUM"),
2735            (32, "WEAPON_FOCUS_BLASTER"),
2736            (33, "WEAPON_FOCUS_BLASTER_RIFLE"),
2737            (34, "XXXX_WEAPON_FOCUS_GRENADE"),
2738            (35, "WEAPON_FOCUS_HEAVY_WEAPONS"),
2739            (36, "WEAPON_FOCUS_LIGHTSABER"),
2740            (37, "WEAPON_FOCUS_MELEE_WEAPONS"),
2741            (38, "XXXX_WEAPON_FOCUS_SIMPLE_WEAPONS"),
2742            (39, "WEAPON_PROF_BLASTER"),
2743            (40, "WEAPON_PROF_BLASTER_RIFLE"),
2744            (41, "XXXX_WEAPON_PROF_GRENADE"),
2745            (42, "WEAPON_PROF_HEAVY_WEAPONS"),
2746            (43, "WEAPON_PROF_LIGHTSABER"),
2747            (44, "WEAPON_PROF_MELEE_WEAPONS"),
2748            (45, "XXXX_WEAPON_PROF_SIMPLE_WEAPONS"),
2749            (46, "WEAPON_SPEC_BLASTER"),
2750            (47, "WEAPON_SPEC_BLASTER_RIFLE"),
2751            (48, "XXXX_WEAPON_SPEC_GRENADE"),
2752            (49, "WEAPON_SPEC_HEAVY_WEAPONS"),
2753            (50, "WEAPON_SPEC_LIGHTSABER"),
2754            (51, "WEAPON_SPEC_MELEE_WEAPONS"),
2755            (52, "XXXX_WEAPON_SPEC_SIMPLE_WEAPONS"),
2756            (93, "PROFICIENCY_ALL"),
2757        ])
2758    }
2759
2760    fn snapshot_feat_kind(feat: &DecodedFeat) -> &'static str {
2761        match feat {
2762            DecodedFeat::ArmourProfLight { .. } => "ArmourProfLight",
2763            DecodedFeat::ArmourProfMedium { .. } => "ArmourProfMedium",
2764            DecodedFeat::ArmourProfHeavy { .. } => "ArmourProfHeavy",
2765            DecodedFeat::WeaponProfBlaster { .. } => "WeaponProfBlaster",
2766            DecodedFeat::WeaponProfBlasterRifle { .. } => "WeaponProfBlasterRifle",
2767            DecodedFeat::WeaponProfHeavyWeapons { .. } => "WeaponProfHeavyWeapons",
2768            DecodedFeat::WeaponProfMeleeWeapons { .. } => "WeaponProfMeleeWeapons",
2769            DecodedFeat::WeaponProfLightsaber { .. } => "WeaponProfLightsaber",
2770            DecodedFeat::WeaponProfGrenade { .. } => "WeaponProfGrenade",
2771            DecodedFeat::WeaponProfSimpleWeapons { .. } => "WeaponProfSimpleWeapons",
2772            DecodedFeat::ProficiencyAll { .. } => "ProficiencyAll",
2773            DecodedFeat::WeaponFocusBlaster { .. } => "WeaponFocusBlaster",
2774            DecodedFeat::WeaponFocusBlasterRifle { .. } => "WeaponFocusBlasterRifle",
2775            DecodedFeat::WeaponFocusGrenade { .. } => "WeaponFocusGrenade",
2776            DecodedFeat::WeaponFocusHeavyWeapons { .. } => "WeaponFocusHeavyWeapons",
2777            DecodedFeat::WeaponFocusLightsaber { .. } => "WeaponFocusLightsaber",
2778            DecodedFeat::WeaponFocusMeleeWeapons { .. } => "WeaponFocusMeleeWeapons",
2779            DecodedFeat::WeaponFocusSimpleWeapons { .. } => "WeaponFocusSimpleWeapons",
2780            DecodedFeat::WeaponSpecBlaster { .. } => "WeaponSpecBlaster",
2781            DecodedFeat::WeaponSpecBlasterRifle { .. } => "WeaponSpecBlasterRifle",
2782            DecodedFeat::WeaponSpecGrenade { .. } => "WeaponSpecGrenade",
2783            DecodedFeat::WeaponSpecHeavyWeapons { .. } => "WeaponSpecHeavyWeapons",
2784            DecodedFeat::WeaponSpecLightsaber { .. } => "WeaponSpecLightsaber",
2785            DecodedFeat::WeaponSpecMeleeWeapons { .. } => "WeaponSpecMeleeWeapons",
2786            DecodedFeat::WeaponSpecSimpleWeapons { .. } => "WeaponSpecSimpleWeapons",
2787            DecodedFeat::PowerAttack { .. } => "PowerAttack",
2788            DecodedFeat::ImprovedPowerAttack { .. } => "ImprovedPowerAttack",
2789            DecodedFeat::MasterPowerAttack { .. } => "MasterPowerAttack",
2790            DecodedFeat::PowerBlast { .. } => "PowerBlast",
2791            DecodedFeat::ImprovedPowerBlast { .. } => "ImprovedPowerBlast",
2792            DecodedFeat::MasterPowerBlast { .. } => "MasterPowerBlast",
2793            DecodedFeat::CriticalStrike { .. } => "CriticalStrike",
2794            DecodedFeat::ImprovedCriticalStrike { .. } => "ImprovedCriticalStrike",
2795            DecodedFeat::MasterCriticalStrike { .. } => "MasterCriticalStrike",
2796            DecodedFeat::Flurry { .. } => "Flurry",
2797            DecodedFeat::ImprovedFlurry { .. } => "ImprovedFlurry",
2798            DecodedFeat::SniperShot { .. } => "SniperShot",
2799            DecodedFeat::ImprovedSniperShot { .. } => "ImprovedSniperShot",
2800            DecodedFeat::MasterSniperShot { .. } => "MasterSniperShot",
2801            DecodedFeat::RapidShot { .. } => "RapidShot",
2802            DecodedFeat::ImprovedRapidShot { .. } => "ImprovedRapidShot",
2803            DecodedFeat::MultiShot { .. } => "MultiShot",
2804            DecodedFeat::WhirlwindAttack { .. } => "WhirlwindAttack",
2805            DecodedFeat::TwoWeaponFighting { .. } => "TwoWeaponFighting",
2806            DecodedFeat::TwoWeaponAdvanced { .. } => "TwoWeaponAdvanced",
2807            DecodedFeat::TwoWeaponMastery { .. } => "TwoWeaponMastery",
2808            DecodedFeat::JediDefense { .. } => "JediDefense",
2809            DecodedFeat::AdvancedJediDefense { .. } => "AdvancedJediDefense",
2810            DecodedFeat::MasterJediDefense { .. } => "MasterJediDefense",
2811            DecodedFeat::ForceFocus { .. } => "ForceFocus",
2812            DecodedFeat::ForceFocusAdvanced { .. } => "ForceFocusAdvanced",
2813            DecodedFeat::ForceFocusMastery { .. } => "ForceFocusMastery",
2814            DecodedFeat::ForceFocusAlter { .. } => "ForceFocusAlter",
2815            DecodedFeat::ForceFocusControl { .. } => "ForceFocusControl",
2816            DecodedFeat::ForceJump { .. } => "ForceJump",
2817            DecodedFeat::ForceJumpAdvanced { .. } => "ForceJumpAdvanced",
2818            DecodedFeat::ForceJumpMastery { .. } => "ForceJumpMastery",
2819            DecodedFeat::ForceImmunityFear { .. } => "ForceImmunityFear",
2820            DecodedFeat::ForceImmunityStun { .. } => "ForceImmunityStun",
2821            DecodedFeat::ForceImmunityParalysis { .. } => "ForceImmunityParalysis",
2822            DecodedFeat::BattleMeditation { .. } => "BattleMeditation",
2823            DecodedFeat::ForceCamoflage { .. } => "ForceCamoflage",
2824            DecodedFeat::SneakAttack1D6 { .. } => "SneakAttack1D6",
2825            DecodedFeat::SneakAttack2D6 { .. } => "SneakAttack2D6",
2826            DecodedFeat::SneakAttack3D6 { .. } => "SneakAttack3D6",
2827            DecodedFeat::SneakAttack4D6 { .. } => "SneakAttack4D6",
2828            DecodedFeat::SneakAttack5D6 { .. } => "SneakAttack5D6",
2829            DecodedFeat::SneakAttack6D6 { .. } => "SneakAttack6D6",
2830            DecodedFeat::UncannyDodge1 { .. } => "UncannyDodge1",
2831            DecodedFeat::UncannyDodge2 { .. } => "UncannyDodge2",
2832            DecodedFeat::Toughness { .. } => "Toughness",
2833            DecodedFeat::ImprovedToughness { .. } => "ImprovedToughness",
2834            DecodedFeat::MasterToughness { .. } => "MasterToughness",
2835            DecodedFeat::Conditioning { .. } => "Conditioning",
2836            DecodedFeat::ImprovedConditioning { .. } => "ImprovedConditioning",
2837            DecodedFeat::MasterConditioning { .. } => "MasterConditioning",
2838            DecodedFeat::ImplantLevel1 { .. } => "ImplantLevel1",
2839            DecodedFeat::ImplantLevel2 { .. } => "ImplantLevel2",
2840            DecodedFeat::ImplantLevel3 { .. } => "ImplantLevel3",
2841            DecodedFeat::DroidUpgrade1 { .. } => "DroidUpgrade1",
2842            DecodedFeat::DroidUpgrade2 { .. } => "DroidUpgrade2",
2843            DecodedFeat::DroidUpgrade3 { .. } => "DroidUpgrade3",
2844            DecodedFeat::BlasterIntegration { .. } => "BlasterIntegration",
2845            DecodedFeat::LogicUpgradeCombat { .. } => "LogicUpgradeCombat",
2846            DecodedFeat::LogicUpgradeTactician { .. } => "LogicUpgradeTactician",
2847            DecodedFeat::Dueling { .. } => "Dueling",
2848            DecodedFeat::AdvancedDueling { .. } => "AdvancedDueling",
2849            DecodedFeat::MasterDueling { .. } => "MasterDueling",
2850            DecodedFeat::JediSense { .. } => "JediSense",
2851            DecodedFeat::KnightSense { .. } => "KnightSense",
2852            DecodedFeat::MasterSense { .. } => "MasterSense",
2853            DecodedFeat::GuardStance { .. } => "GuardStance",
2854            DecodedFeat::AdvancedGuardStance { .. } => "AdvancedGuardStance",
2855            DecodedFeat::MasterGuardStance { .. } => "MasterGuardStance",
2856            DecodedFeat::SkillFocusAwareness { .. } => "SkillFocusAwareness",
2857            DecodedFeat::SkillFocusTreatInjury { .. } => "SkillFocusTreatInjury",
2858            DecodedFeat::SkillFocusStealth { .. } => "SkillFocusStealth",
2859            DecodedFeat::Cautious { .. } => "Cautious",
2860            DecodedFeat::Perceptive { .. } => "Perceptive",
2861            DecodedFeat::Empathy { .. } => "Empathy",
2862            DecodedFeat::GearHead { .. } => "GearHead",
2863            DecodedFeat::WookieEndurance { .. } => "WookieEndurance",
2864            DecodedFeat::ScoundrelsLuck { .. } => "ScoundrelsLuck",
2865            DecodedFeat::Unknown { .. } => "<unknown>",
2866        }
2867    }
2868
2869    #[test]
2870    fn feats_dispatch_armour_proficiency_labels_to_typed_variants() {
2871        let utc = Utc {
2872            feats: vec![4, 5, 6],
2873            ..Utc::default()
2874        };
2875        let mut overrides = OverrideSource::new();
2876        add_2da_entry(&mut overrides, "feat", &weapon_feats_2da());
2877        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2878        let mut cache = TwoDaCache::new(&resolver);
2879
2880        let snapshot = utc.snapshot(&mut cache);
2881        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
2882        assert_eq!(
2883            kinds,
2884            vec!["ArmourProfHeavy", "ArmourProfLight", "ArmourProfMedium"]
2885        );
2886    }
2887
2888    #[test]
2889    fn feats_dispatch_weapon_proficiency_labels_to_typed_variants() {
2890        // Includes XXXX_*-prefixed deprecated variants that vanilla
2891        // UTCs still load (grenade, simple weapons).
2892        let utc = Utc {
2893            feats: vec![39, 40, 41, 42, 43, 44, 45, 93],
2894            ..Utc::default()
2895        };
2896        let mut overrides = OverrideSource::new();
2897        add_2da_entry(&mut overrides, "feat", &weapon_feats_2da());
2898        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2899        let mut cache = TwoDaCache::new(&resolver);
2900
2901        let snapshot = utc.snapshot(&mut cache);
2902        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
2903        assert_eq!(
2904            kinds,
2905            vec![
2906                "WeaponProfBlaster",
2907                "WeaponProfBlasterRifle",
2908                "WeaponProfGrenade",
2909                "WeaponProfHeavyWeapons",
2910                "WeaponProfLightsaber",
2911                "WeaponProfMeleeWeapons",
2912                "WeaponProfSimpleWeapons",
2913                "ProficiencyAll",
2914            ]
2915        );
2916    }
2917
2918    #[test]
2919    fn feats_dispatch_weapon_focus_labels_to_typed_variants() {
2920        let utc = Utc {
2921            feats: vec![32, 33, 34, 35, 36, 37, 38],
2922            ..Utc::default()
2923        };
2924        let mut overrides = OverrideSource::new();
2925        add_2da_entry(&mut overrides, "feat", &weapon_feats_2da());
2926        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
2927        let mut cache = TwoDaCache::new(&resolver);
2928
2929        let snapshot = utc.snapshot(&mut cache);
2930        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
2931        assert_eq!(
2932            kinds,
2933            vec![
2934                "WeaponFocusBlaster",
2935                "WeaponFocusBlasterRifle",
2936                "WeaponFocusGrenade",
2937                "WeaponFocusHeavyWeapons",
2938                "WeaponFocusLightsaber",
2939                "WeaponFocusMeleeWeapons",
2940                "WeaponFocusSimpleWeapons",
2941            ]
2942        );
2943    }
2944
2945    fn combat_feats_2da() -> TwoDa {
2946        label_only_2da(&[
2947            (3, "TWO_WEAPON_FIGHTING"),
2948            (8, "CRITICAL_STRIKE"),
2949            (9, "TWO_WEAPON_ADVANCED"),
2950            (11, "FLURRY"),
2951            (17, "IMPROVED_POWER_ATTACK"),
2952            (18, "IMPROVED_POWER_BLAST"),
2953            (19, "IMPROVED_CRITICAL_STRIKE"),
2954            (20, "IMPROVED_SNIPER_SHOT"),
2955            (26, "MULTI_SHOT"),
2956            (28, "POWER_ATTACK"),
2957            (29, "POWER_BLAST"),
2958            (30, "RAPID_SHOT"),
2959            (31, "SNIPER_SHOT"),
2960            (53, "WHIRLWIND_ATTACK"),
2961            (77, "MASTER_SNIPER_SHOT"),
2962            (81, "MASTER_CRITICAL_STRIKE"),
2963            (82, "MASTER_POWER_BLAST"),
2964            (83, "MASTER_POWER_ATTACK"),
2965            (85, "TWO_WEAPON_MASTERY"),
2966            (91, "IMPROVED_FLURRY"),
2967            (92, "IMPROVED_RAPID_SHOT"),
2968        ])
2969    }
2970
2971    fn long_tail_feats_2da() -> TwoDa {
2972        label_only_2da(&[
2973            (2, "XXXX_ADVANCED_GUARD_STANCE"),
2974            (7, "CAUTIOUS"),
2975            (10, "EMPATHY"),
2976            (12, "GEAR_HEAD"),
2977            (25, "XXXX_MASTER_GUARD_STANCE"),
2978            (27, "XXXX_PERCEPTIVE"),
2979            (54, "XXXX_GUARD_STANCE"),
2980            (71, "XXXX_SKILL_FOCUS_STEALTH"),
2981            (72, "XXXX_SKILL_FOCUS_AWARENESS"),
2982            (76, "XXXX_SKILL_FOCUS_TREAT_INJURY"),
2983            (78, "DROID_UPGRADE_1"),
2984            (79, "DROID_UPGRADE_2"),
2985            (80, "DROID_UPGRADE_3"),
2986            (95, "WOOKIE_ENDURANCE"),
2987            (96, "BLASTER_INTEGRATION"),
2988            (104, "SCOUNDRELS_LUCK"),
2989            (107, "JEDI_SENSE"),
2990            (108, "KNIGHT_SENSE"),
2991            (109, "MASTER_SENSE"),
2992            (110, "LOGIC_UPGRADE_COMBAT"),
2993            (111, "LOGIC_UPGRADE_TACTICIAN"),
2994            (113, "DUELING"),
2995            (114, "ADVANCED_DUELING"),
2996            (115, "MASTER_DUELING"),
2997        ])
2998    }
2999
3000    #[test]
3001    fn feats_dispatch_long_tail_labels_to_typed_variants() {
3002        // Every remaining vanilla-corpus feat, in row-id order.
3003        // Together with the previous family dispatch tests, this
3004        // closes the gap on vanilla coverage.
3005        let utc = Utc {
3006            feats: vec![
3007                2, 7, 10, 12, 25, 27, 54, 71, 72, 76, 78, 79, 80, 95, 96, 104, 107, 108, 109, 110,
3008                111, 113, 114, 115,
3009            ],
3010            ..Utc::default()
3011        };
3012        let mut overrides = OverrideSource::new();
3013        add_2da_entry(&mut overrides, "feat", &long_tail_feats_2da());
3014        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3015        let mut cache = TwoDaCache::new(&resolver);
3016
3017        let snapshot = utc.snapshot(&mut cache);
3018        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
3019        assert_eq!(
3020            kinds,
3021            vec![
3022                "AdvancedGuardStance",
3023                "Cautious",
3024                "Empathy",
3025                "GearHead",
3026                "MasterGuardStance",
3027                "Perceptive",
3028                "GuardStance",
3029                "SkillFocusStealth",
3030                "SkillFocusAwareness",
3031                "SkillFocusTreatInjury",
3032                "DroidUpgrade1",
3033                "DroidUpgrade2",
3034                "DroidUpgrade3",
3035                "WookieEndurance",
3036                "BlasterIntegration",
3037                "ScoundrelsLuck",
3038                "JediSense",
3039                "KnightSense",
3040                "MasterSense",
3041                "LogicUpgradeCombat",
3042                "LogicUpgradeTactician",
3043                "Dueling",
3044                "AdvancedDueling",
3045                "MasterDueling",
3046            ]
3047        );
3048    }
3049
3050    fn survival_feats_2da() -> TwoDa {
3051        label_only_2da(&[
3052            (13, "CONDITIONING"),
3053            (14, "IMPLANT_LEVEL_1"),
3054            (15, "IMPLANT_LEVEL_2"),
3055            (16, "IMPLANT_LEVEL_3"),
3056            (21, "IMPROVED_CONDITIONING"),
3057            (22, "MASTER_CONDITIONING"),
3058            (56, "UNCANNY_DODGE_1"),
3059            (57, "UNCANNY_DODGE_2"),
3060            (60, "SNEAK_ATTACK_1D6"),
3061            (61, "SNEAK_ATTACK_2D6"),
3062            (62, "SNEAK_ATTACK_3D6"),
3063            (63, "SNEAK_ATTACK_4D6"),
3064            (64, "SNEAK_ATTACK_5D6"),
3065            (65, "SNEAK_ATTACK_6D6"),
3066            (84, "TOUGHNESS"),
3067            (123, "IMPROVED_TOUGHNESS"),
3068            (124, "MASTER_TOUGHNESS"),
3069        ])
3070    }
3071
3072    #[test]
3073    fn feats_dispatch_survival_labels_to_typed_variants() {
3074        let utc = Utc {
3075            feats: vec![
3076                13, 14, 15, 16, 21, 22, 56, 57, 60, 61, 62, 63, 64, 65, 84, 123, 124,
3077            ],
3078            ..Utc::default()
3079        };
3080        let mut overrides = OverrideSource::new();
3081        add_2da_entry(&mut overrides, "feat", &survival_feats_2da());
3082        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3083        let mut cache = TwoDaCache::new(&resolver);
3084
3085        let snapshot = utc.snapshot(&mut cache);
3086        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
3087        assert_eq!(
3088            kinds,
3089            vec![
3090                "Conditioning",
3091                "ImplantLevel1",
3092                "ImplantLevel2",
3093                "ImplantLevel3",
3094                "ImprovedConditioning",
3095                "MasterConditioning",
3096                "UncannyDodge1",
3097                "UncannyDodge2",
3098                "SneakAttack1D6",
3099                "SneakAttack2D6",
3100                "SneakAttack3D6",
3101                "SneakAttack4D6",
3102                "SneakAttack5D6",
3103                "SneakAttack6D6",
3104                "Toughness",
3105                "ImprovedToughness",
3106                "MasterToughness",
3107            ]
3108        );
3109    }
3110
3111    fn force_feats_2da() -> TwoDa {
3112        label_only_2da(&[
3113            (1, "ADVANCED_JEDI_DEFENSE"),
3114            (24, "MASTER_JEDI_DEFENSE"),
3115            (55, "JEDI_DEFENSE"),
3116            (86, "XXXX_FORCE_FOCUS_ALTER"),
3117            (87, "XXXX_FORCE_FOCUS_CONTROL"),
3118            (88, "FORCE_FOCUS"),
3119            (89, "FORCE_FOCUS_ADVANCED"),
3120            (90, "FORCE_FOCUS_MASTERY"),
3121            (94, "BATTLE_MEDITATION"),
3122            (97, "FORCE_CAMOFLAGE"),
3123            (98, "FORCE_IMMUNITY_FEAR"),
3124            (99, "FORCE_IMMUNITY_STUN"),
3125            (100, "FORCE_IMMUNITY_PARALYSIS"),
3126            (101, "FORCE_JUMP"),
3127            (102, "FORCE_JUMP_ADVANCED"),
3128            (103, "FORCE_JUMP_MASTERY"),
3129        ])
3130    }
3131
3132    #[test]
3133    fn feats_dispatch_force_jedi_labels_to_typed_variants() {
3134        let utc = Utc {
3135            feats: vec![
3136                1, 24, 55, 86, 87, 88, 89, 90, 94, 97, 98, 99, 100, 101, 102, 103,
3137            ],
3138            ..Utc::default()
3139        };
3140        let mut overrides = OverrideSource::new();
3141        add_2da_entry(&mut overrides, "feat", &force_feats_2da());
3142        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3143        let mut cache = TwoDaCache::new(&resolver);
3144
3145        let snapshot = utc.snapshot(&mut cache);
3146        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
3147        assert_eq!(
3148            kinds,
3149            vec![
3150                "AdvancedJediDefense",
3151                "MasterJediDefense",
3152                "JediDefense",
3153                "ForceFocusAlter",
3154                "ForceFocusControl",
3155                "ForceFocus",
3156                "ForceFocusAdvanced",
3157                "ForceFocusMastery",
3158                "BattleMeditation",
3159                "ForceCamoflage",
3160                "ForceImmunityFear",
3161                "ForceImmunityStun",
3162                "ForceImmunityParalysis",
3163                "ForceJump",
3164                "ForceJumpAdvanced",
3165                "ForceJumpMastery",
3166            ]
3167        );
3168    }
3169
3170    #[test]
3171    fn feats_dispatch_combat_ladders_to_typed_variants() {
3172        // Every combat-technique ladder, in feat_id order so the
3173        // expected list is easy to read.
3174        let utc = Utc {
3175            feats: vec![
3176                3, 8, 9, 11, 17, 18, 19, 20, 26, 28, 29, 30, 31, 53, 77, 81, 82, 83, 85, 91, 92,
3177            ],
3178            ..Utc::default()
3179        };
3180        let mut overrides = OverrideSource::new();
3181        add_2da_entry(&mut overrides, "feat", &combat_feats_2da());
3182        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3183        let mut cache = TwoDaCache::new(&resolver);
3184
3185        let snapshot = utc.snapshot(&mut cache);
3186        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
3187        assert_eq!(
3188            kinds,
3189            vec![
3190                "TwoWeaponFighting",
3191                "CriticalStrike",
3192                "TwoWeaponAdvanced",
3193                "Flurry",
3194                "ImprovedPowerAttack",
3195                "ImprovedPowerBlast",
3196                "ImprovedCriticalStrike",
3197                "ImprovedSniperShot",
3198                "MultiShot",
3199                "PowerAttack",
3200                "PowerBlast",
3201                "RapidShot",
3202                "SniperShot",
3203                "WhirlwindAttack",
3204                "MasterSniperShot",
3205                "MasterCriticalStrike",
3206                "MasterPowerBlast",
3207                "MasterPowerAttack",
3208                "TwoWeaponMastery",
3209                "ImprovedFlurry",
3210                "ImprovedRapidShot",
3211            ]
3212        );
3213    }
3214
3215    #[test]
3216    fn feats_dispatch_weapon_specialization_labels_to_typed_variants() {
3217        let utc = Utc {
3218            feats: vec![46, 47, 48, 49, 50, 51, 52],
3219            ..Utc::default()
3220        };
3221        let mut overrides = OverrideSource::new();
3222        add_2da_entry(&mut overrides, "feat", &weapon_feats_2da());
3223        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3224        let mut cache = TwoDaCache::new(&resolver);
3225
3226        let snapshot = utc.snapshot(&mut cache);
3227        let kinds: Vec<&str> = snapshot.feats().iter().map(snapshot_feat_kind).collect();
3228        assert_eq!(
3229            kinds,
3230            vec![
3231                "WeaponSpecBlaster",
3232                "WeaponSpecBlasterRifle",
3233                "WeaponSpecGrenade",
3234                "WeaponSpecHeavyWeapons",
3235                "WeaponSpecLightsaber",
3236                "WeaponSpecMeleeWeapons",
3237                "WeaponSpecSimpleWeapons",
3238            ]
3239        );
3240    }
3241
3242    #[test]
3243    fn feats_dispatch_by_label_so_mod_renumbered_feat_still_routes() {
3244        // Mod relocates ARMOUR_PROF_LIGHT to row 500 in feat.2da.
3245        // The UTC's feat_id is 500; dispatch is by label, so it
3246        // still routes to DecodedFeat::ArmourProfLight rather than
3247        // Unknown.
3248        let utc = Utc {
3249            feats: vec![500],
3250            ..Utc::default()
3251        };
3252        let feat = label_only_2da(&[(500, "ARMOUR_PROF_LIGHT")]);
3253        let mut overrides = OverrideSource::new();
3254        add_2da_entry(&mut overrides, "feat", &feat);
3255        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3256        let mut cache = TwoDaCache::new(&resolver);
3257
3258        let snapshot = utc.snapshot(&mut cache);
3259        match &snapshot.feats()[0] {
3260            DecodedFeat::ArmourProfLight { feat_id } => {
3261                // feat_id preserved from the source GFF, not from
3262                // any vanilla-default lookup.
3263                assert_eq!(*feat_id, 500);
3264            }
3265            other => panic!("expected ArmourProfLight, got {other:?}"),
3266        }
3267    }
3268
3269    #[test]
3270    fn feat_with_unknown_label_surfaces_as_unknown_carrying_resolved_label() {
3271        // Mod adds row 5000 with a label our dispatch does not know.
3272        // Result is Unknown with the resolved label surfaced.
3273        let utc = Utc {
3274            feats: vec![5000],
3275            ..Utc::default()
3276        };
3277        let feat = label_only_2da(&[(5000, "MOD_MYSTERY_FEAT")]);
3278        let mut overrides = OverrideSource::new();
3279        add_2da_entry(&mut overrides, "feat", &feat);
3280        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3281        let mut cache = TwoDaCache::new(&resolver);
3282
3283        let snapshot = utc.snapshot(&mut cache);
3284        match &snapshot.feats()[0] {
3285            DecodedFeat::Unknown {
3286                feat_id,
3287                feat_label,
3288            } => {
3289                assert_eq!(*feat_id, 5000);
3290                assert_eq!(feat_label.as_deref(), Some("MOD_MYSTERY_FEAT"));
3291            }
3292            other => panic!("expected Unknown, got {other:?}"),
3293        }
3294    }
3295
3296    #[test]
3297    fn feat_decodes_to_unknown_with_no_label_when_id_out_of_range() {
3298        // feat.2da has 2 rows but the UTC carries feat_id 99. Label
3299        // resolution yields None; the raw id round-trips intact.
3300        let utc = Utc {
3301            feats: vec![99],
3302            ..Utc::default()
3303        };
3304        let feat = label_only_2da(&[(0, ""), (1, "ADVANCED_JEDI_DEFENSE")]);
3305        let mut overrides = OverrideSource::new();
3306        add_2da_entry(&mut overrides, "feat", &feat);
3307        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3308        let mut cache = TwoDaCache::new(&resolver);
3309
3310        let snapshot = utc.snapshot(&mut cache);
3311        match &snapshot.feats()[0] {
3312            DecodedFeat::Unknown {
3313                feat_id,
3314                feat_label,
3315            } => {
3316                assert_eq!(*feat_id, 99);
3317                assert!(feat_label.is_none());
3318            }
3319            other => panic!("expected Unknown, got {other:?}"),
3320        }
3321    }
3322
3323    #[test]
3324    fn feat_decodes_to_unknown_with_no_label_when_feat_2da_unavailable() {
3325        // Resolver knows nothing about feat.2da; every entry surfaces
3326        // as Unknown with no label regardless of whether the id maps
3327        // to a typed variant under a resolved table.
3328        let utc = Utc {
3329            feats: vec![5, 93],
3330            ..Utc::default()
3331        };
3332        let resolver = Resolver::new();
3333        let mut cache = TwoDaCache::new(&resolver);
3334
3335        let snapshot = utc.snapshot(&mut cache);
3336        for entry in snapshot.feats() {
3337            match entry {
3338                DecodedFeat::Unknown { feat_label, .. } => assert!(feat_label.is_none()),
3339                other => panic!("expected Unknown without label, got {other:?}"),
3340            }
3341        }
3342    }
3343
3344    #[test]
3345    fn empty_feat_list_decodes_to_empty_slice() {
3346        let utc = Utc::default();
3347        let resolver = Resolver::new();
3348        let mut cache = TwoDaCache::new(&resolver);
3349
3350        let snapshot = utc.snapshot(&mut cache);
3351        assert!(snapshot.feats().is_empty());
3352    }
3353
3354    #[test]
3355    fn feat_id_accessor_returns_raw_id_regardless_of_variant() {
3356        let unknown = DecodedFeat::Unknown {
3357            feat_id: 84,
3358            feat_label: Some("TOUGHNESS".to_string()),
3359        };
3360        assert_eq!(unknown.feat_id(), 84);
3361        let typed = DecodedFeat::ArmourProfLight { feat_id: 5 };
3362        assert_eq!(typed.feat_id(), 5);
3363    }
3364
3365    #[test]
3366    fn feats_preserve_stacked_duplicates_as_typed_variant_slots() {
3367        // Engine load path appends without dedup; UTC carrying the
3368        // same typed feat_id N times surfaces as N separate slots.
3369        let utc = Utc {
3370            feats: vec![5, 5, 5],
3371            ..Utc::default()
3372        };
3373        let mut overrides = OverrideSource::new();
3374        add_2da_entry(&mut overrides, "feat", &weapon_feats_2da());
3375        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3376        let mut cache = TwoDaCache::new(&resolver);
3377
3378        let snapshot = utc.snapshot(&mut cache);
3379        assert_eq!(snapshot.feats().len(), 3);
3380        for entry in snapshot.feats() {
3381            assert!(matches!(entry, DecodedFeat::ArmourProfLight { feat_id: 5 }));
3382        }
3383    }
3384
3385    #[test]
3386    fn one_projection_yields_diverging_feat_dispatch_per_scope() {
3387        // Mod conflict scenario: same UTC, feat_id 5 maps to vanilla
3388        // ARMOUR_PROF_LIGHT under one scope and a mod label under
3389        // another. Vanilla routes to a typed variant; the mod scope
3390        // falls into Unknown carrying the mod's label.
3391        let utc = Utc {
3392            feats: vec![5],
3393            ..Utc::default()
3394        };
3395
3396        let mut vanilla_overrides = OverrideSource::new();
3397        add_2da_entry(&mut vanilla_overrides, "feat", &weapon_feats_2da());
3398        let vanilla_resolver =
3399            Resolver::new().with_source(ResolverSourceRef::Override(&vanilla_overrides));
3400
3401        let mod_feat = label_only_2da(&[(5, "MOD_FIBER_ARMOUR")]);
3402        let mut mod_overrides = OverrideSource::new();
3403        add_2da_entry(&mut mod_overrides, "feat", &mod_feat);
3404        let mod_resolver = Resolver::new().with_source(ResolverSourceRef::Override(&mod_overrides));
3405
3406        let projection = utc.project();
3407        let mut vanilla_cache = TwoDaCache::new(&vanilla_resolver);
3408        let mut mod_cache = TwoDaCache::new(&mod_resolver);
3409
3410        let vanilla = projection.snapshot(&mut vanilla_cache);
3411        let modded = projection.snapshot(&mut mod_cache);
3412
3413        assert!(matches!(
3414            vanilla.feats()[0],
3415            DecodedFeat::ArmourProfLight { feat_id: 5 }
3416        ));
3417        match &modded.feats()[0] {
3418            DecodedFeat::Unknown {
3419                feat_id,
3420                feat_label,
3421            } => {
3422                assert_eq!(*feat_id, 5);
3423                assert_eq!(feat_label.as_deref(), Some("MOD_FIBER_ARMOUR"));
3424            }
3425            other => panic!("expected Unknown, got {other:?}"),
3426        }
3427    }
3428
3429    // -- FeatKindFilter --
3430
3431    #[test]
3432    fn has_feat_matches_each_family_filter() {
3433        // One UTC carrying a representative feat per family, then
3434        // every family filter is asserted to flip.
3435        let mut overrides = OverrideSource::new();
3436        // Merge fixtures so a single 2DA covers every label the
3437        // dispatch tests below need.
3438        let combined = label_only_2da(&[
3439            (5, "ARMOUR_PROF_LIGHT"),
3440            (28, "POWER_ATTACK"),
3441            (39, "WEAPON_PROF_BLASTER"),
3442            (32, "WEAPON_FOCUS_BLASTER"),
3443            (46, "WEAPON_SPEC_BLASTER"),
3444            (29, "POWER_BLAST"),
3445            (8, "CRITICAL_STRIKE"),
3446            (11, "FLURRY"),
3447            (31, "SNIPER_SHOT"),
3448            (30, "RAPID_SHOT"),
3449            (3, "TWO_WEAPON_FIGHTING"),
3450            (55, "JEDI_DEFENSE"),
3451            (88, "FORCE_FOCUS"),
3452            (101, "FORCE_JUMP"),
3453            (98, "FORCE_IMMUNITY_FEAR"),
3454            (60, "SNEAK_ATTACK_1D6"),
3455            (56, "UNCANNY_DODGE_1"),
3456            (84, "TOUGHNESS"),
3457            (13, "CONDITIONING"),
3458            (14, "IMPLANT_LEVEL_1"),
3459            (78, "DROID_UPGRADE_1"),
3460            (113, "DUELING"),
3461            (107, "JEDI_SENSE"),
3462            (54, "XXXX_GUARD_STANCE"),
3463            (72, "XXXX_SKILL_FOCUS_AWARENESS"),
3464        ]);
3465        add_2da_entry(&mut overrides, "feat", &combined);
3466        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3467
3468        let utc = Utc {
3469            feats: vec![
3470                5, 28, 39, 32, 46, 29, 8, 11, 31, 30, 3, 55, 88, 101, 98, 60, 56, 84, 13, 14, 78,
3471                113, 107, 54, 72,
3472            ],
3473            ..Utc::default()
3474        };
3475        let mut cache = TwoDaCache::new(&resolver);
3476        let snapshot = utc.snapshot(&mut cache);
3477
3478        let cases = [
3479            (FeatKindFilter::AnyArmourProficiency, true),
3480            (FeatKindFilter::AnyWeaponProficiency, true),
3481            (FeatKindFilter::AnyWeaponFocus, true),
3482            (FeatKindFilter::AnyWeaponSpecialization, true),
3483            (FeatKindFilter::AnyPowerAttack, true),
3484            (FeatKindFilter::AnyPowerBlast, true),
3485            (FeatKindFilter::AnyCriticalStrike, true),
3486            (FeatKindFilter::AnyFlurry, true),
3487            (FeatKindFilter::AnySniperShot, true),
3488            (FeatKindFilter::AnyRapidShot, true),
3489            (FeatKindFilter::AnyTwoWeapon, true),
3490            (FeatKindFilter::AnyJediDefense, true),
3491            (FeatKindFilter::AnyForceFocus, true),
3492            (FeatKindFilter::AnyForceJump, true),
3493            (FeatKindFilter::AnyForceImmunity, true),
3494            (FeatKindFilter::AnySneakAttack, true),
3495            (FeatKindFilter::AnyUncannyDodge, true),
3496            (FeatKindFilter::AnyToughness, true),
3497            (FeatKindFilter::AnyConditioning, true),
3498            (FeatKindFilter::AnyImplant, true),
3499            (FeatKindFilter::AnyDroidUpgrade, true),
3500            (FeatKindFilter::AnyDueling, true),
3501            (FeatKindFilter::AnySense, true),
3502            (FeatKindFilter::AnyGuardStance, true),
3503            (FeatKindFilter::AnySkillFocus, true),
3504        ];
3505        for (filter, expected) in cases {
3506            assert_eq!(
3507                snapshot.has_feat(filter),
3508                expected,
3509                "filter {filter:?} expected {expected}"
3510            );
3511        }
3512    }
3513
3514    #[test]
3515    fn has_feat_returns_false_for_empty_feat_list() {
3516        let utc = Utc::default();
3517        let resolver = Resolver::new();
3518        let mut cache = TwoDaCache::new(&resolver);
3519        let snapshot = utc.snapshot(&mut cache);
3520        for filter in [
3521            FeatKindFilter::AnyArmourProficiency,
3522            FeatKindFilter::AnyPowerAttack,
3523            FeatKindFilter::AnyForceJump,
3524            FeatKindFilter::AnySneakAttack,
3525        ] {
3526            assert!(!snapshot.has_feat(filter));
3527        }
3528    }
3529
3530    #[test]
3531    fn has_feat_never_matches_unknown_entries() {
3532        // A UTC carrying only mod-added feats (Unknown variants) is
3533        // never matched by any family filter, even ones whose name
3534        // intuitively suggests the mod's intent.
3535        let utc = Utc {
3536            feats: vec![5000],
3537            ..Utc::default()
3538        };
3539        let feat = label_only_2da(&[(5000, "MOD_POWER_ATTACK_REWORK")]);
3540        let mut overrides = OverrideSource::new();
3541        add_2da_entry(&mut overrides, "feat", &feat);
3542        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3543        let mut cache = TwoDaCache::new(&resolver);
3544        let snapshot = utc.snapshot(&mut cache);
3545
3546        assert!(matches!(snapshot.feats()[0], DecodedFeat::Unknown { .. }));
3547        assert!(!snapshot.has_feat(FeatKindFilter::AnyPowerAttack));
3548        assert!(!snapshot.has_feat(FeatKindFilter::AnyArmourProficiency));
3549    }
3550
3551    #[test]
3552    fn has_feat_distinguishes_specific_ladder_from_neighbour() {
3553        // A UTC with Power Attack but no Power Blast: AnyPowerAttack
3554        // flips on, AnyPowerBlast stays off. Same shape across
3555        // ladders, this just sanity-checks the matchers don't bleed.
3556        let utc = Utc {
3557            feats: vec![28],
3558            ..Utc::default()
3559        };
3560        let feat = label_only_2da(&[(28, "POWER_ATTACK")]);
3561        let mut overrides = OverrideSource::new();
3562        add_2da_entry(&mut overrides, "feat", &feat);
3563        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
3564        let mut cache = TwoDaCache::new(&resolver);
3565        let snapshot = utc.snapshot(&mut cache);
3566
3567        assert!(snapshot.has_feat(FeatKindFilter::AnyPowerAttack));
3568        assert!(!snapshot.has_feat(FeatKindFilter::AnyPowerBlast));
3569        assert!(!snapshot.has_feat(FeatKindFilter::AnyCriticalStrike));
3570        assert!(!snapshot.has_feat(FeatKindFilter::AnyForceJump));
3571    }
3572}