Skip to main content

rakata_generics/decoded/
utc.rs

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