Skip to main content

rakata_generics/
utc.rs

1//! UTC (`.utc`) typed generic wrapper.
2//!
3//! Creatures cover every NPC and monster: identity, class and level,
4//! appearance, equipment and event scripts. The heaviest of these formats by
5//! field count, and the one that carries the most save-game state.
6//!
7//! ## Coverage
8//! - Typed access for creature fields (identity, scripts, core stats, appearance,
9//!   demographics, combat, AI, and movement).
10//! - Typed handling for `SkillList` and `ClassList` / canonical `KnownList0` spell data.
11//! - Typed handling for `SpecAbilityList` (`Spell`, `SpellFlags`, `SpellCasterLevel`).
12//! - Typed handling for `FeatList`, `Equip_ItemList`, and `ItemList`.
13//!
14//! ## Field Layout (simplified)
15//! ```text
16//! UTC root struct
17//! +-- TemplateResRef / Tag / Comment / Conversation
18//! +-- FirstName / LastName               (CExoLocString)
19//! +-- Appearance_Type / Gender / Race / FactionID / WalkRate
20//! +-- Age / StartingPackage / Gold / Experience
21//! +-- Color_Skin / Color_Hair / Color_Tattoo1 / Color_Tattoo2
22//! +-- Appearance_Head / DuplicatingHead / UseBackupHead
23//! +-- AIState / SkillPoints / MovementRate / Invulnerable
24//! +-- Character stats / saves / HP / FP
25//! +-- Script* hooks                      (CResRef)
26//! +-- SkillList                          (List<Struct Rank>)
27//! +-- ClassList                          (List<Struct>)
28//! |   +-- Class / ClassLevel
29//! |   `-- KnownList0                     (List<Struct Spell>)
30//! +-- SpecAbilityList                    (List<Struct Spell/SpellFlags/SpellCasterLevel>)
31//! +-- FeatList                           (List<Struct Feat>)
32//! +-- Equip_ItemList                     (List<Struct EquippedRes/Dropable>)
33//! +-- ItemList                           (List<Struct InventoryRes/...>)
34//! ```
35
36use std::io::{Cursor, Read, Write};
37
38use rakata_core::ResRef;
39use rakata_formats::gff::{get_i32, get_u16, get_u8, upsert_field, GffLabel};
40use rakata_formats::gff_label;
41use rakata_formats::schema::FromGff;
42use rakata_formats::GENERIC_FILE_TYPE;
43use rakata_formats::{
44    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
45    GffStruct, GffValue,
46};
47use thiserror::Error;
48
49/// Typed UTC model built from/to [`Gff`] data.
50#[derive(Debug, Clone, PartialEq, GffModel)]
51#[gff_entry(CreatureSize, wire = i32, stamped = 3)]
52#[gff_entry(IsDestroyable, wire = u8, stamped = 1)]
53#[gff_entry(IsRaiseable, wire = u8, stamped = 1)]
54#[gff_entry(DeadSelectable, wire = u8, stamped = 1)]
55#[gff_entry(AmbientAnimState, wire = u8, stamped)]
56#[gff_entry(Animation, wire = i32, stamped = 10000)]
57#[gff_entry(CreatnScrptFird, wire = u8, stamped)]
58#[gff_entry(PM_IsDisguised, wire = u8, stamped)]
59#[gff_entry(PM_Appearance, wire = u16, stamped)]
60#[gff_entry(Listening, wire = u8, stamped)]
61#[gff_entry(AreaId, wire = u32, stamped)]
62#[gff_entry(DetectMode, wire = u8, stamped)]
63#[gff_entry(StealthMode, wire = u8, stamped)]
64#[gff_entry(LvlStatList, container = list, not_a_constant)]
65pub struct Utc {
66    /// Maximum hit points (`MaxHitPoints`).
67    #[gff(
68        MaxHitPoints,
69        write_only_dead = "the field-name string has exactly two cross-references in the binary, both writers, and zero readers anywhere on any path",
70        not_a_constant
71    )]
72    pub max_hp: i16,
73    /// Localized first name (`FirstName`).
74    #[gff(FirstName, stamped)]
75    pub first_name: GffLocalizedString,
76    /// Localized last name (`LastName`).
77    #[gff(LastName, stamped)]
78    pub last_name: GffLocalizedString,
79    /// Deprecated localized description (`Description`).
80    #[gff(Description, constructed)]
81    pub description: GffLocalizedString,
82    /// Player-character flag (`IsPC`).
83    #[gff(IsPC, unexamined)]
84    pub is_pc: bool,
85    /// Creature tag (`Tag`).
86    #[gff(Tag, constructed)]
87    pub tag: String,
88    /// Conversation resref (`Conversation`).
89    #[gff(Conversation, constructed)]
90    pub conversation: ResRef,
91    /// Interruptable flag (`Interruptable`).
92    #[gff(Interruptable, constructed)]
93    pub interruptable: bool,
94    /// Age (`Age`).
95    #[gff(Age, constructed)]
96    pub age: i32,
97    /// Gender ID (`Gender`). Natively clamped by the engine to a maximum of 4.
98    #[gff(Gender, constructed, range_int = (0, 4))]
99    pub gender_id: u8,
100    /// Starting package ID (`StartingPackage`).
101    #[gff(StartingPackage, constructed)]
102    pub starting_package: u8,
103    /// Race ID (`Race`).
104    #[gff(Race, unexamined)]
105    pub race_id: u8,
106    /// Deprecated textual subrace (`Subrace`).
107    #[gff(Subrace, constructed)]
108    pub subrace_name: String,
109    /// Subrace ID (`SubraceIndex`).
110    #[gff(SubraceIndex, constructed)]
111    pub subrace_id: u8,
112    /// Deprecated deity field (`Deity`).
113    #[gff(Deity, constructed)]
114    pub deity: String,
115    /// Strength (`Str`).
116    #[gff(Str, constructed)]
117    pub strength: u8,
118    /// Dexterity (`Dex`).
119    #[gff(Dex, constructed)]
120    pub dexterity: u8,
121    /// Intelligence (`Int`).
122    #[gff(Int, constructed)]
123    pub intelligence: u8,
124    /// Wisdom (`Wis`).
125    #[gff(Wis, constructed)]
126    pub wisdom: u8,
127    /// Constitution (`Con`).
128    #[gff(Con, constructed)]
129    pub constitution: u8,
130    /// Charisma (`Cha`).
131    #[gff(Cha, constructed)]
132    pub charisma: u8,
133    /// Natural armor class (`NaturalAC`).
134    #[gff(NaturalAC, constructed)]
135    pub natural_ac: u8,
136    /// Sound-set ID (`SoundSetFile`).
137    #[gff(SoundSetFile, stamped = 65535)]
138    pub soundset_id: u16,
139    /// Gold carried (`Gold`).
140    #[gff(Gold, constructed)]
141    pub gold: u32,
142    /// Invulnerability flag (`Invulnerable`).
143    #[gff(Invulnerable, constructed, omit = audited_constant(2361))]
144    pub invulnerable: bool,
145    /// Plot flag (`Plot`).
146    #[gff(Plot, constructed)]
147    pub plot: bool,
148    /// Min-1HP flag (`Min1HP`).
149    #[gff(Min1HP, constructed)]
150    pub min1_hp: bool,
151    /// Party-interact flag (`PartyInteract`).
152    #[gff(PartyInteract, constructed)]
153    pub party_interact: bool,
154    /// Non-reorienting flag (`NotReorienting`).
155    #[gff(NotReorienting, constructed)]
156    pub not_reorienting: bool,
157    /// Disarmable flag (`Disarmable`).
158    #[gff(Disarmable, constructed)]
159    pub disarmable: bool,
160    /// Accumulated experience (`Experience`).
161    #[gff(Experience, constructed)]
162    pub experience: u32,
163    /// Portrait ID (`PortraitId`).
164    #[gff(PortraitId, stamped = 65535)]
165    pub portrait_id: u16,
166    /// Portrait resref override (`Portrait`).
167    #[gff(Portrait, constructed)]
168    pub portrait_resref: ResRef,
169    /// Good/Evil alignment (`GoodEvil`). Natively clamped by the engine to a maximum of 100.
170    #[gff(GoodEvil, constructed, range_int = (0, 100))]
171    pub alignment: u8,
172    /// Skin color index (`Color_Skin`).
173    #[gff(Color_Skin, constructed)]
174    pub color_skin: u8,
175    /// Hair color index (`Color_Hair`).
176    #[gff(Color_Hair, constructed)]
177    pub color_hair: u8,
178    /// First tattoo color index (`Color_Tattoo1`).
179    #[gff(Color_Tattoo1, constructed)]
180    pub color_tattoo1: u8,
181    /// Second tattoo color index (`Color_Tattoo2`).
182    #[gff(Color_Tattoo2, constructed)]
183    pub color_tattoo2: u8,
184    /// Deprecated phenotype (`Phenotype`).
185    #[gff(Phenotype, constructed)]
186    pub phenotype_id: i32,
187    /// Appearance ID (`Appearance_Type`).
188    #[gff(Appearance_Type, constructed)]
189    pub appearance_id: u16,
190    /// Head appearance index (`Appearance_Head`). If 0, the engine forces this to 1 at runtime.
191    #[gff(Appearance_Head, constructed)]
192    pub appearance_head: u8,
193    /// Duplicating-head index (`DuplicatingHead`).
194    #[gff(DuplicatingHead, constructed)]
195    pub duplicating_head: u8,
196    /// Backup-head flag (`UseBackupHead`).
197    #[gff(UseBackupHead, constructed)]
198    pub use_backup_head: u8,
199    /// Faction ID (`FactionID`).
200    #[gff(FactionID, constructed)]
201    pub faction_id: u16,
202    /// Challenge rating (`ChallengeRating`).
203    #[gff(ChallengeRating, constructed)]
204    pub challenge_rating: f32,
205    /// AI state flags (`AIState`).
206    #[gff(AIState, constructed, wire = i32)]
207    pub ai_state: u16,
208    /// Body-bag ID (`BodyBag`).
209    #[gff(BodyBag, constructed)]
210    pub bodybag_id: u8,
211    /// Perception range ID (`PerceptionRange`).
212    #[gff(PerceptionRange, stamped = 11)]
213    pub perception_id: u8,
214    /// Will save bonus (`willbonus`).
215    #[gff(willbonus, constructed)]
216    pub willpower_bonus: i16,
217    /// Fortitude save bonus (`fortbonus`).
218    #[gff(fortbonus, constructed)]
219    pub fortitude_bonus: i16,
220    /// Reflex save bonus (`refbonus`).
221    #[gff(refbonus, constructed)]
222    pub reflex_bonus: i16,
223    /// Base hit points (`HitPoints`).
224    #[gff(HitPoints, live, constructed = 1)]
225    pub hp: i16,
226    /// Maximum force points (`ForcePoints`).
227    #[gff(ForcePoints, constructed)]
228    pub max_fp: i16,
229    /// Current hit points (`CurrentHitPoints`).
230    #[gff(CurrentHitPoints, live, not_a_constant)]
231    pub current_hp: i16,
232    /// Current force points (`CurrentForce`).
233    #[gff(CurrentForce, not_a_constant)]
234    pub fp: i16,
235    /// Unspent skill points (`SkillPoints`).
236    #[gff(SkillPoints, constructed)]
237    pub skill_points: u16,
238    /// Movement rate ID (`MovementRate`).
239    #[gff(MovementRate, manual_read, from_siblings, not_a_constant)]
240    pub movement_rate: u8,
241    /// Walk-rate ID (`WalkRate`).
242    #[gff(WalkRate, not_a_constant)]
243    pub walkrate_id: i32,
244    /// On-heartbeat script (`ScriptHeartbeat`).
245    #[gff(ScriptHeartbeat, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
246    pub on_heartbeat: ResRef,
247    /// On-notice script (`ScriptOnNotice`).
248    #[gff(ScriptOnNotice, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
249    pub on_notice: ResRef,
250    /// On-spell-at script (`ScriptSpellAt`).
251    #[gff(ScriptSpellAt, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
252    pub on_spell: ResRef,
253    /// On-attacked script (`ScriptAttacked`).
254    #[gff(ScriptAttacked, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
255    pub on_attacked: ResRef,
256    /// On-damaged script (`ScriptDamaged`).
257    #[gff(ScriptDamaged, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
258    pub on_damaged: ResRef,
259    /// On-disturbed script (`ScriptDisturbed`).
260    #[gff(ScriptDisturbed, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
261    pub on_disturbed: ResRef,
262    /// On-end-round script (`ScriptEndRound`).
263    #[gff(ScriptEndRound, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
264    pub on_end_round: ResRef,
265    /// On-dialogue script (`ScriptDialogue`).
266    #[gff(ScriptDialogue, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
267    pub on_dialog: ResRef,
268    /// On-spawn script (`ScriptSpawn`).
269    #[gff(ScriptSpawn, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
270    pub on_spawn: ResRef,
271    /// On-rested script (`ScriptRested`).
272    #[gff(ScriptRested, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
273    pub on_rested: ResRef,
274    /// On-death script (`ScriptDeath`).
275    #[gff(ScriptDeath, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
276    pub on_death: ResRef,
277    /// On-user-defined script (`ScriptUserDefine`).
278    #[gff(ScriptUserDefine, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
279    pub on_user_defined: ResRef,
280    /// On-blocked script (`ScriptOnBlocked`).
281    #[gff(ScriptOnBlocked, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
282    pub on_blocked: ResRef,
283    /// On-end-dialog script (`ScriptEndDialogu`).
284    #[gff(ScriptEndDialogu, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
285    pub on_end_dialog: ResRef,
286    /// Class entries from `ClassList`.
287    ///
288    /// A creature object carries two class slots, so a longer list has nowhere
289    /// to put the rest. What the loader does with a third valid entry is not
290    /// traced; see `docs/src/formats/gff/utc.md`. A duplicate or out-of-range
291    /// class id is traced, and both crash the engine with `0x5f7`.
292    #[gff(ClassList, unexamined, list = UtcClass, element_id = 2)]
293    pub classes: Vec<UtcClass>,
294    /// Feat identifiers from `FeatList` (`Feat`).
295    #[gff(FeatList, manual_read, manual_write, unexamined, list = UtcFeatEntry, element_id = 1)]
296    pub feats: Vec<u16>,
297    /// Skill ranks from `SkillList`.
298    #[gff(SkillList, manual_read, manual_write, not_a_constant, list = UtcSkillRank, element_id = 0)]
299    pub skills: UtcSkills,
300    /// Equipped-item entries from `Equip_ItemList`.
301    #[gff(Equip_ItemList, manual_read, manual_write, unexamined, list = UtcEquipmentItem, element_id = meaningful)]
302    pub equipment: Vec<UtcEquipmentItem>,
303    /// Inventory entries from `ItemList`.
304    #[gff(ItemList, not_a_constant, list = UtcInventoryItem, element_id = positional)]
305    pub inventory: Vec<UtcInventoryItem>,
306    /// Special abilities from `SpecAbilityList`.
307    #[gff(SpecAbilityList, not_a_constant, list = UtcSpecialAbility, element_id = 4)]
308    pub special_abilities: Vec<UtcSpecialAbility>,
309    /// Creature template resref (`TemplateResRef`).
310    #[gff(TemplateResRef, not_a_constant)]
311    pub template_resref: ResRef,
312    /// Toolset/comment field (`Comment`). Never read by the K1 engine.
313    #[gff(
314        Comment,
315        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
316        not_a_constant
317    )]
318    pub comment: String,
319    /// Palette ID (`PaletteID`). Never read by the K1 engine.
320    #[gff(
321        PaletteID,
322        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
323        not_a_constant
324    )]
325    pub palette_id: u8,
326    /// Will save (`SaveWill`). Strictly ignored by the KOTOR engine; reads `willbonus` instead.
327    #[gff(
328        SaveWill,
329        read_only_dead = "the template's own save-throw fields are ignored; the engine computes saving throws from base plus ability modifier plus active effects and reads these never",
330        not_a_constant
331    )]
332    pub save_will: u8,
333    /// Fortitude save (`SaveFortitude`). Strictly ignored by the KOTOR engine; reads `fortbonus` instead.
334    #[gff(
335        SaveFortitude,
336        read_only_dead = "the template's own save-throw fields are ignored; the engine computes saving throws from base plus ability modifier plus active effects and reads these never",
337        not_a_constant
338    )]
339    pub save_fortitude: u8,
340    /// Body variation (`BodyVariation`). Never read by the K1 engine.
341    #[gff(
342        BodyVariation,
343        read_only_dead = "the string exists in the binary but its only cross-references are the item loader and saver, so it is a real field name on a different format and no creature function reads it",
344        not_a_constant
345    )]
346    pub body_variation: u8,
347    /// Texture variation (`TextureVar`). Never read by the K1 engine.
348    #[gff(
349        TextureVar,
350        read_only_dead = "the string exists in the binary but its only cross-references are the item loader and saver, so it is a real field name on a different format and no creature function reads it",
351        not_a_constant
352    )]
353    pub texture_variation: u8,
354    /// Morale (`Morale`). Never read by the K1 engine.
355    #[gff(
356        Morale,
357        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
358        not_a_constant
359    )]
360    pub morale: u8,
361    /// Morale recovery (`MoraleRecovery`). Never read by the K1 engine.
362    #[gff(
363        MoraleRecovery,
364        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
365        not_a_constant
366    )]
367    pub morale_recovery: u8,
368    /// Morale breakpoint (`MoraleBreakpoint`). Never read by the K1 engine.
369    #[gff(
370        MoraleBreakpoint,
371        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
372        not_a_constant
373    )]
374    pub morale_breakpoint: u8,
375    /// Blind-spot value (`BlindSpot`). Never read by the K1 engine.
376    #[gff(
377        BlindSpot,
378        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
379        not_a_constant
380    )]
381    pub blindspot: f32,
382    /// Multiplier-set ID (`MultiplierSet`). Never read by the K1 engine.
383    #[gff(
384        MultiplierSet,
385        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
386        not_a_constant
387    )]
388    pub multiplier_set: u8,
389    /// Permanent-death disabled flag (`NoPermDeath`). Never read by the K1 engine.
390    #[gff(
391        NoPermDeath,
392        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
393        not_a_constant
394    )]
395    pub no_perm_death: bool,
396    /// Ignore creature pathing (`IgnoreCrePath`). Never read by the K1 engine.
397    #[gff(
398        IgnoreCrePath,
399        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
400        not_a_constant
401    )]
402    pub ignore_cre_path: bool,
403    /// Hologram flag (`Hologram`). Never read by the K1 engine.
404    #[gff(
405        Hologram,
406        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
407        not_a_constant
408    )]
409    pub hologram: bool,
410    /// Will-not-render flag (`WillNotRender`). Never read by the K1 engine.
411    #[gff(
412        WillNotRender,
413        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
414        not_a_constant
415    )]
416    pub will_not_render: bool,
417    /// Deprecated lawfulness alignment (`LawfulChaotic`). Never read by the K1 engine.
418    #[gff(
419        LawfulChaotic,
420        read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
421        not_a_constant
422    )]
423    pub lawfulness: u8,
424}
425
426/// The `Spell` value that means "no power here".
427///
428/// `ReadSpellsFromGff` skips a `KnownList0` entry whose `Spell` resolves to
429/// this, appending nothing, so it is a drop marker rather than a value to
430/// load. An absent `Spell` resolves to it too, which is why both cases take
431/// the same branch.
432const SPELL_ABSENT: u16 = 0xFFFF;
433
434impl UtcClass {
435    /// Reads one `ClassList` element.
436    ///
437    /// The derive supplies neither element half for a type with a
438    /// hand-written codec, so the powers rule lives here: a `Spell` defaults
439    /// to the absent sentinel and the whole entry is skipped on it.
440    fn read_element(structure: &GffStruct) -> Self {
441        let mut class = Self::read_declared(structure);
442        class.powers = parse_known_list(structure, "KnownList0");
443        class
444    }
445
446    /// Writes one `ClassList` element.
447    fn write_element(&self, structure: &mut GffStruct) {
448        self.write_declared(structure);
449        write_known_list(structure, gff_label!("KnownList0"), &self.powers);
450    }
451}
452
453/// One `SkillList` element, which carries the rank and nothing else.
454///
455/// Declared only to give the entry its element schema. The model holds the
456/// eight ranks by name in [`UtcSkills`], because the list is a fixed-size
457/// positional array rather than a collection.
458#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
459struct UtcSkillRank {
460    #[gff(Rank, required, unexamined)]
461    rank: u8,
462}
463
464/// One `KnownList0` element.
465///
466/// Declared only to give the entry its element schema; the model holds the
467/// power identifiers alone, because an entry at the absent sentinel is one the
468/// engine drops rather than one it loads at some default.
469///
470/// The two flags beside the id are written and never read back. They are here
471/// because the view emits them, and a schema that left them out would say this
472/// list holds one label when the file and this crate's writer both carry three.
473#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
474struct UtcKnownPower {
475    #[gff(Spell, unexamined)]
476    spell: u16,
477    #[gff(SpellFlags, unexamined)]
478    spell_flags: u8,
479    #[gff(SpellMetaMagic, unexamined)]
480    spell_meta_magic: u8,
481}
482
483/// One `FeatList` element, which carries the feat id and nothing else.
484///
485/// Declared only to give the entry its element schema; the model holds the
486/// ids directly, since an entry has nothing else in it.
487#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
488struct UtcFeatEntry {
489    #[gff(Feat, required, unexamined)]
490    feat: u16,
491}
492
493/// Skill ranks mirrored from UTC `SkillList` index ordering.
494#[derive(Debug, Clone, PartialEq, Default)]
495pub struct UtcSkills {
496    /// Skill index `0` (`Computer Use`).
497    pub computer_use: u8,
498    /// Skill index `1` (`Demolitions`).
499    pub demolitions: u8,
500    /// Skill index `2` (`Stealth`).
501    pub stealth: u8,
502    /// Skill index `3` (`Awareness`).
503    pub awareness: u8,
504    /// Skill index `4` (`Persuade`).
505    pub persuade: u8,
506    /// Skill index `5` (`Repair`).
507    pub repair: u8,
508    /// Skill index `6` (`Security`).
509    pub security: u8,
510    /// Skill index `7` (`Treat Injury`).
511    pub treat_injury: u8,
512}
513
514impl UtcSkills {
515    fn from_list(list: &[GffStruct]) -> Self {
516        Self {
517            computer_use: read_skill_rank(list, 0),
518            demolitions: read_skill_rank(list, 1),
519            stealth: read_skill_rank(list, 2),
520            awareness: read_skill_rank(list, 3),
521            persuade: read_skill_rank(list, 4),
522            repair: read_skill_rank(list, 5),
523            security: read_skill_rank(list, 6),
524            treat_injury: read_skill_rank(list, 7),
525        }
526    }
527
528    fn to_list(&self) -> Vec<GffStruct> {
529        let mut list = default_skill_structs();
530
531        write_skill_rank(&mut list[0], self.computer_use);
532        write_skill_rank(&mut list[1], self.demolitions);
533        write_skill_rank(&mut list[2], self.stealth);
534        write_skill_rank(&mut list[3], self.awareness);
535        write_skill_rank(&mut list[4], self.persuade);
536        write_skill_rank(&mut list[5], self.repair);
537        write_skill_rank(&mut list[6], self.security);
538        write_skill_rank(&mut list[7], self.treat_injury);
539
540        list
541    }
542}
543
544impl Utc {
545    /// Creates an empty UTC value.
546    pub fn new() -> Self {
547        Self::default()
548    }
549
550    /// Builds typed UTC data from a parsed GFF container.
551    ///
552    /// # Errors
553    ///
554    /// Returns [`UtcError::UnsupportedFileType`] for a container that is
555    /// neither `UTC ` nor the generic `GFF ` form.
556    pub fn from_gff(gff: &Gff) -> Result<Self, UtcError> {
557        if gff.file_type != <Utc as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
558            return Err(UtcError::UnsupportedFileType(gff.file_type));
559        }
560
561        let root = &gff.root;
562        let mut utc = Self::read_declared(root);
563
564        // Three lists whose elements the model unwraps, each by a different
565        // rule. `utc.md` states the divergence: the skills are a fixed-size
566        // positional array needing a defined baseline, a feat contributes
567        // nothing where it is absent because feats have no slots to reset, and
568        // a power is skipped on the sentinel its read defaults to.
569        if let Some(GffValue::List(ranks)) = root.field("SkillList") {
570            utc.skills = UtcSkills::from_list(ranks);
571        }
572        // The slot is the element's own struct id rather than any label, so
573        // the read takes it off the header the way the engine does.
574        if let Some(GffValue::List(items)) = root.field("Equip_ItemList") {
575            utc.equipment = items
576                .iter()
577                .map(|element| {
578                    let mut item = UtcEquipmentItem::read_declared(element);
579                    item.slot_id = element.struct_id;
580                    item
581                })
582                .collect();
583        }
584        // `MovementRate` falls back to `WalkRate`, which is a sibling rather
585        // than a constant. What the pair resolves to when neither is present
586        // is not documented, so the reader keeps the zero it already holds.
587        utc.movement_rate = get_u8(root, "MovementRate")
588            .or_else(|| get_i32(root, "WalkRate").and_then(|v| u8::try_from(v).ok()))
589            .unwrap_or(0);
590        if let Some(GffValue::List(feats)) = root.field("FeatList") {
591            utc.feats = feats
592                .iter()
593                .map(|entry| get_u16(entry, "Feat").unwrap_or(0))
594                .collect();
595        }
596
597        Ok(utc)
598    }
599
600    /// Converts this typed UTC value into a GFF container.
601    pub fn to_gff(&self) -> Gff {
602        let mut root = GffStruct::new(-1);
603        self.write_declared(&mut root);
604
605        upsert_field(
606            &mut root,
607            gff_label!("SkillList"),
608            GffValue::List(self.skills.to_list()),
609        );
610        upsert_field(
611            &mut root,
612            gff_label!("FeatList"),
613            GffValue::List(
614                self.feats
615                    .iter()
616                    .map(|feat| {
617                        let mut element = GffStruct::new(1);
618                        element.push_field(gff_label!("Feat"), GffValue::UInt16(*feat));
619                        element
620                    })
621                    .collect(),
622            ),
623        );
624        // The element's struct id is the equipment slot the engine reads off
625        // the header, so it comes from the entry rather than from a counter.
626        upsert_field(
627            &mut root,
628            gff_label!("Equip_ItemList"),
629            GffValue::List(
630                self.equipment
631                    .iter()
632                    .map(|item| {
633                        let mut element = GffStruct::new(item.slot_id);
634                        item.write_declared(&mut element);
635                        element
636                    })
637                    .collect(),
638            ),
639        );
640
641        Gff::new(*b"UTC ", root)
642    }
643}
644
645/// Typed UTC one entry of `ClassList`.
646#[derive(Debug, Clone, PartialEq, GffModel)]
647#[gff_entry(SpellsPerDayList, container = list, not_a_constant)]
648pub struct UtcClass {
649    /// Spell/power identifiers from `KnownList0` (`Spell`).
650    #[gff(
651        KnownList0,
652        manual_read,
653        manual_write,
654        not_a_constant,
655        list = UtcKnownPower,
656        element_id = 3
657    )]
658    pub powers: Vec<u16>,
659    /// Class identifier (`Class`).
660    #[gff(Class, unexamined)]
661    pub class_id: i32,
662    /// Class level (`ClassLevel`).
663    #[gff(ClassLevel, unexamined)]
664    pub class_level: i16,
665}
666
667/// Typed UTC one entry of `SpecAbilityList`.
668#[derive(Debug, Clone, PartialEq, GffModel)]
669pub struct UtcSpecialAbility {
670    /// Ability spell identifier (`Spell`).
671    #[gff(Spell, required, unexamined)]
672    pub spell_id: u16,
673    /// Ability behavior flags (`SpellFlags`).
674    #[gff(SpellFlags, unexamined)]
675    pub spell_flags: u8,
676    /// Ability caster level (`SpellCasterLevel`).
677    #[gff(SpellCasterLevel, unexamined)]
678    pub spell_caster_level: u8,
679}
680
681/// Typed UTC one entry of `Equip_ItemList`.
682#[derive(Debug, Clone, PartialEq, GffModel)]
683#[gff_entry(ObjectId, wire = u32, stamped = 2130706432)]
684pub struct UtcEquipmentItem {
685    /// Equipment slot, which is the element's own struct id rather than a
686    /// label. The engine reads it off the GFF element header, so it has to be
687    /// carried somewhere and no field in the file repeats it.
688    pub slot_id: i32,
689    /// Equipped item resref (`EquippedRes`).
690    #[gff(EquippedRes, required, not_a_constant)]
691    pub resref: ResRef,
692    /// Drop flag (`Dropable`).
693    #[gff(Dropable, stamped)]
694    pub droppable: bool,
695}
696
697/// Typed UTC one entry of `ItemList`.
698#[derive(Debug, Clone, PartialEq, GffModel)]
699#[gff_entry(Infinite, wire = u8, read_only_dead = "the Infinite label has exactly two cross-references in the binary, both inside CSWSStore::LoadStore and SaveStore, so no function in this type's item-loading call graph reads it", not_a_constant)]
700#[gff_entry(ObjectId, wire = u32, stamped = 2130706432)]
701#[gff_entry(Repos_PosY, wire = u16, read_only_dead = "never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches", not_a_constant)]
702pub struct UtcInventoryItem {
703    /// Item template resref (`InventoryRes`).
704    #[gff(InventoryRes, required, not_a_constant)]
705    pub resref: ResRef,
706    /// Drop flag (`Dropable`).
707    #[gff(Dropable, stamped)]
708    pub droppable: bool,
709    /// Optional inventory grid X (`Repos_PosX`).
710    #[gff(Repos_PosX, read_only_dead = "never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches", not_a_constant, optional = u16)]
711    pub repos_pos_x: Option<u16>,
712    /// Optional inventory grid Y (`Repos_Posy`).
713    #[gff(Repos_Posy, read_only_dead = "never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches", not_a_constant, optional = u16)]
714    pub repos_pos_y: Option<u16>,
715}
716
717fn read_skill_rank(list: &[GffStruct], index: usize) -> u8 {
718    list.get(index)
719        .and_then(|skill| get_u8(skill, "Rank"))
720        .unwrap_or(0)
721}
722
723fn write_skill_rank(skill: &mut GffStruct, rank: u8) {
724    upsert_field(skill, gff_label!("Rank"), GffValue::UInt8(rank));
725}
726
727fn default_skill_structs() -> Vec<GffStruct> {
728    let mut list = Vec::with_capacity(8);
729    for _ in 0..8 {
730        let mut skill = GffStruct::new(0);
731        upsert_field(&mut skill, gff_label!("Rank"), GffValue::UInt8(0));
732        list.push(skill);
733    }
734    list
735}
736
737fn parse_known_list(structure: &GffStruct, label: &'static str) -> Vec<u16> {
738    match structure.field(label) {
739        // A power whose `Spell` is absent, or explicitly the `0xFFFF`
740        // sentinel, is dropped by the engine rather than loaded at some
741        // default: `ReadSpellsFromGff` never appends the entry. Defaulting to
742        // `0` and pushing it invented a power the creature does not have, and
743        // `0` is a real `spells.2da` row rather than a harmless placeholder.
744        Some(GffValue::List(power_structs)) => power_structs
745            .iter()
746            .filter_map(|power_struct| get_u16(power_struct, "Spell"))
747            .filter(|spell| *spell != SPELL_ABSENT)
748            .collect::<Vec<_>>(),
749        _ => Vec::new(),
750    }
751}
752
753fn write_known_list(structure: &mut GffStruct, label: GffLabel, powers: &[u16]) {
754    let power_structs = powers
755        .iter()
756        .copied()
757        .map(|spell| {
758            let mut s = GffStruct::new(3);
759            upsert_field(&mut s, gff_label!("Spell"), GffValue::UInt16(spell));
760            upsert_field(&mut s, gff_label!("SpellFlags"), GffValue::UInt8(1));
761            upsert_field(&mut s, gff_label!("SpellMetaMagic"), GffValue::UInt8(0));
762            s
763        })
764        .collect::<Vec<_>>();
765
766    upsert_field(structure, label, GffValue::List(power_structs));
767}
768
769/// Errors produced while reading or writing typed UTC data.
770#[derive(Debug, Error)]
771pub enum UtcError {
772    /// Source file type is not supported by this parser.
773    #[error("unsupported UTC file type: {0:?}")]
774    UnsupportedFileType([u8; 4]),
775    /// Underlying GFF parser/writer error.
776    #[error(transparent)]
777    Gff(#[from] GffBinaryError),
778}
779
780/// Reads typed UTC data from a reader at the current stream position.
781///
782/// # Errors
783///
784/// [`UtcError::Gff`] when the stream is not a readable GFF, and
785/// [`UtcError::UnsupportedFileType`] when it is a GFF of some other format,
786/// carrying the fourcc that was found.
787#[cfg_attr(
788    feature = "tracing",
789    tracing::instrument(level = "debug", skip(reader))
790)]
791pub fn read_utc<R: Read>(reader: &mut R) -> Result<Utc, UtcError> {
792    let gff = read_gff(reader)?;
793    Utc::from_gff(&gff)
794}
795
796/// Reads typed UTC data directly from bytes.
797///
798/// # Errors
799///
800/// [`UtcError::Gff`] when `bytes` are not a readable GFF, and
801/// [`UtcError::UnsupportedFileType`] when they are a GFF of some other format,
802/// carrying the fourcc that was found.
803#[cfg_attr(
804    feature = "tracing",
805    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
806)]
807pub fn read_utc_from_bytes(bytes: &[u8]) -> Result<Utc, UtcError> {
808    let gff = read_gff_from_bytes(bytes)?;
809    Utc::from_gff(&gff)
810}
811
812/// Authors the UTC file the typed view describes, into a writer.
813///
814/// # Errors
815///
816/// [`UtcError::Gff`] when the writer fails or a value will not encode. The
817/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
818/// this side.
819#[cfg_attr(
820    feature = "tracing",
821    tracing::instrument(level = "debug", skip(writer, utc))
822)]
823pub fn author_utc<W: Write>(writer: &mut W, utc: &Utc) -> Result<(), UtcError> {
824    let gff = utc.to_gff();
825    write_gff(writer, &gff)?;
826    Ok(())
827}
828
829/// Authors the UTC file the typed view describes, as bytes.
830///
831/// # Errors
832///
833/// [`UtcError::Gff`] when a value will not encode. Writing into a `Vec` has no
834/// I/O to fail at.
835#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utc)))]
836pub fn author_utc_to_vec(utc: &Utc) -> Result<Vec<u8>, UtcError> {
837    let mut cursor = Cursor::new(Vec::new());
838    author_utc(&mut cursor, utc)?;
839    Ok(cursor.into_inner())
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use rakata_core::StrRef;
846    use rakata_formats::schema::{HasSchema, Shape};
847    use rakata_formats::{gff_label, GffValue};
848
849    const TEST_UTC: &[u8] = include_bytes!(concat!(
850        env!("CARGO_MANIFEST_DIR"),
851        "/../../fixtures/test.utc"
852    ));
853
854    /// A power entry the engine would drop must not survive the read.
855    ///
856    /// `ReadSpellsFromGff` appends nothing for an entry whose `Spell` is absent
857    /// or holds the `0xFFFF` sentinel. Defaulting it to `0` and pushing anyway
858    /// invented a power the creature does not have, and `0` names a real
859    /// `spells.2da` row rather than an obviously-empty one.
860    #[test]
861    fn known_list_drops_the_entries_the_engine_never_appends() {
862        let entry = |spell: Option<u16>| {
863            let mut s = GffStruct::new(3);
864            if let Some(value) = spell {
865                upsert_field(&mut s, gff_label!("Spell"), GffValue::UInt16(value));
866            }
867            s
868        };
869        let mut list = GffStruct::new(0);
870        upsert_field(
871            &mut list,
872            gff_label!("KnownList0"),
873            GffValue::List(vec![
874                entry(Some(7)),
875                entry(Some(SPELL_ABSENT)),
876                entry(Some(9)),
877                entry(None),
878            ]),
879        );
880
881        let powers = parse_known_list(&list, "KnownList0");
882        assert_eq!(
883            powers,
884            vec![7, 9],
885            "the sentinel entry and the one with no Spell should both be gone"
886        );
887    }
888
889    /// `MovementRate` and `WalkRate` in all three presence combinations.
890    ///
891    /// `utc.md` gives a two-label fallback with an asymmetry that is easy to
892    /// get half right: `WalkRate` is attempted only when `MovementRate` was
893    /// absent, and skipped outright rather than defaulted when it was present.
894    /// A check that only exercised the both-absent case would pass on a reader
895    /// that ignored `WalkRate` entirely.
896    #[test]
897    fn movement_rate_resolves_across_every_presence_combination() {
898        let build = |movement: Option<u8>, walk: Option<i32>| {
899            let mut gff = Gff::new(*b"UTC ", GffStruct::new(0));
900            if let Some(v) = movement {
901                upsert_field(
902                    &mut gff.root,
903                    gff_label!("MovementRate"),
904                    GffValue::UInt8(v),
905                );
906            }
907            if let Some(v) = walk {
908                upsert_field(&mut gff.root, gff_label!("WalkRate"), GffValue::Int32(v));
909            }
910            Utc::from_gff(&gff).expect("a UTC with no required fields parses")
911        };
912
913        // Present wins, and the WalkRate value must not leak into it.
914        assert_eq!(build(Some(3), Some(9)).movement_rate, 3);
915        // Absent falls back to the WalkRate label.
916        assert_eq!(build(None, Some(9)).movement_rate, 9);
917        // Neither present resolves to the constructed zero.
918        assert_eq!(build(None, None).movement_rate, 0);
919        // The file's own WalkRate is still projected faithfully, because the
920        // typed view models the labels the file carries; the engine collapsing
921        // them into one member is resolution and happens elsewhere.
922        assert_eq!(build(Some(3), Some(9)).walkrate_id, 9);
923    }
924
925    #[test]
926    fn reads_core_utc_fields_from_fixture() {
927        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
928
929        assert_eq!(utc.template_resref, "n_minecoorta");
930        assert_eq!(utc.tag, "Coorta");
931        assert_eq!(utc.comment, "comment");
932        assert_eq!(utc.conversation, "coorta");
933
934        assert_eq!(utc.first_name.string_ref.raw(), 76_046);
935        assert_eq!(utc.last_name.string_ref.raw(), 123);
936
937        assert_eq!(utc.age, 25);
938        assert_eq!(utc.starting_package, 3);
939        assert_eq!(utc.gold, 500);
940        assert!(utc.invulnerable);
941        assert_eq!(utc.experience, 1200);
942        assert_eq!(utc.color_skin, 2);
943        assert_eq!(utc.color_hair, 4);
944        assert_eq!(utc.color_tattoo1, 1);
945        assert_eq!(utc.color_tattoo2, 3);
946        assert_eq!(utc.appearance_head, 5);
947        assert_eq!(utc.duplicating_head, 0);
948        assert_eq!(utc.use_backup_head, 0);
949        assert_eq!(utc.ai_state, 100);
950        assert_eq!(utc.skill_points, 8);
951        assert_eq!(utc.movement_rate, 7);
952
953        assert_eq!(utc.appearance_id, 636);
954        assert_eq!(utc.gender_id, 2);
955        assert_eq!(utc.race_id, 6);
956        assert_eq!(utc.faction_id, 5);
957        assert_eq!(utc.perception_id, 11);
958        assert_eq!(utc.walkrate_id, 7);
959        assert_eq!(utc.soundset_id, 46);
960        assert_eq!(utc.portrait_id, 1);
961        assert!(utc.portrait_resref.is_empty());
962        assert_eq!(utc.save_will, 0);
963        assert_eq!(utc.save_fortitude, 0);
964        assert_eq!(utc.morale, 0);
965        assert_eq!(utc.morale_recovery, 0);
966        assert_eq!(utc.morale_breakpoint, 0);
967        assert_eq!(utc.description.string_ref.raw(), 123);
968        assert_eq!(utc.lawfulness, 0);
969        assert_eq!(utc.phenotype_id, 0);
970        assert!(utc.deity.is_empty());
971        assert!(utc.subrace_name.is_empty());
972
973        assert_eq!(utc.alignment, 50);
974        assert!((utc.challenge_rating - 1.0).abs() < f32::EPSILON);
975        assert!((utc.blindspot - 120.0).abs() < f32::EPSILON);
976        assert_eq!(utc.natural_ac, 1);
977        assert_eq!(utc.reflex_bonus, 1);
978        assert_eq!(utc.willpower_bonus, 1);
979        assert_eq!(utc.fortitude_bonus, 1);
980
981        assert_eq!(utc.strength, 10);
982        assert_eq!(utc.dexterity, 10);
983        assert_eq!(utc.constitution, 10);
984        assert_eq!(utc.intelligence, 10);
985        assert_eq!(utc.wisdom, 10);
986        assert_eq!(utc.charisma, 10);
987
988        assert_eq!(utc.current_hp, 8);
989        assert_eq!(utc.max_hp, 8);
990        assert_eq!(utc.hp, 8);
991        assert_eq!(utc.fp, 1);
992        assert_eq!(utc.max_fp, 1);
993
994        assert!(utc.not_reorienting);
995        assert!(utc.party_interact);
996        assert!(utc.no_perm_death);
997        assert!(utc.min1_hp);
998        assert!(utc.plot);
999        assert!(utc.interruptable);
1000        assert!(utc.is_pc);
1001        assert!(utc.disarmable);
1002        assert!(utc.ignore_cre_path);
1003        assert!(utc.hologram);
1004
1005        assert_eq!(utc.on_attacked, "k_def_attacked01");
1006        assert_eq!(utc.on_damaged, "k_def_damage01");
1007        assert_eq!(utc.on_death, "k_def_death01");
1008        assert_eq!(utc.on_dialog, "k_def_dialogue01");
1009        assert_eq!(utc.on_disturbed, "k_def_disturb01");
1010        assert_eq!(utc.on_end_dialog, "k_def_endconv");
1011        assert_eq!(utc.on_end_round, "k_def_combend01");
1012        assert_eq!(utc.on_heartbeat, "k_def_heartbt01");
1013        assert_eq!(utc.on_blocked, "k_def_blocked01");
1014        assert_eq!(utc.on_notice, "k_def_percept01");
1015        assert_eq!(utc.on_spawn, "k_def_spawn01");
1016        assert_eq!(utc.on_spell, "k_def_spellat01");
1017        assert_eq!(utc.on_user_defined, "k_def_userdef01");
1018
1019        assert_eq!(utc.skills.computer_use, 1);
1020        assert_eq!(utc.skills.demolitions, 2);
1021        assert_eq!(utc.skills.stealth, 3);
1022        assert_eq!(utc.skills.awareness, 4);
1023        assert_eq!(utc.skills.persuade, 5);
1024        assert_eq!(utc.skills.repair, 6);
1025        assert_eq!(utc.skills.security, 7);
1026        assert_eq!(utc.skills.treat_injury, 8);
1027
1028        assert_eq!(utc.classes.len(), 2);
1029        let scout_class = utc
1030            .classes
1031            .iter()
1032            .find(|class| class.class_id == 1 && class.class_level == 3)
1033            .expect("fixture should contain class_id=1 level=3");
1034        assert_eq!(scout_class.powers, vec![9, 11]);
1035        assert!(utc.special_abilities.is_empty());
1036
1037        assert_eq!(utc.feats, vec![93, 94]);
1038
1039        assert_eq!(utc.equipment.len(), 2);
1040        assert_eq!(utc.equipment[0].slot_id, 2);
1041        assert_eq!(utc.equipment[0].resref, "mineruniform");
1042        assert!(utc.equipment[0].droppable);
1043        assert_eq!(utc.equipment[1].slot_id, 131_072);
1044        assert_eq!(utc.equipment[1].resref, "g_i_crhide008");
1045        assert!(!utc.equipment[1].droppable);
1046
1047        assert_eq!(utc.inventory.len(), 4);
1048        // No `entry_id`: the engine walks ItemList by position and never
1049        // reads those struct ids, so the model does not carry them.
1050        assert_eq!(utc.inventory[0].resref, "g_w_thermldet01");
1051        assert!(utc.inventory[0].droppable);
1052        assert_eq!(utc.inventory[1].resref, "g_w_thermldet01");
1053        assert!(!utc.inventory[1].droppable);
1054        assert_eq!(utc.inventory[3].resref, "g_w_thermldet02");
1055        assert_eq!(utc.inventory[3].repos_pos_x, Some(3));
1056        assert_eq!(utc.inventory[3].repos_pos_y, Some(0));
1057    }
1058
1059    #[test]
1060    fn all_fields_survive_typed_roundtrip() {
1061        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
1062        let encoded = author_utc_to_vec(&utc).expect("encode must succeed");
1063        let reparsed = read_utc_from_bytes(&encoded).expect("decode must succeed");
1064        assert_eq!(utc, reparsed);
1065    }
1066
1067    #[test]
1068    fn typed_edits_roundtrip_through_gff_writer() {
1069        let mut utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
1070        utc.tag = "Coorta_Mod".into();
1071        utc.on_spawn = ResRef::new("k_new_spawn").expect("valid test resref");
1072        utc.skills.persuade = 12;
1073        utc.portrait_resref = ResRef::new("po_pfha01").expect("valid test resref");
1074        utc.save_will = 11;
1075        utc.save_fortitude = 9;
1076        utc.morale = 8;
1077        utc.morale_recovery = 7;
1078        utc.morale_breakpoint = 6;
1079        utc.description = GffLocalizedString::new(StrRef::from_raw(42_424));
1080        utc.lawfulness = 35;
1081        utc.phenotype_id = 2;
1082        utc.deity = "The Force".into();
1083        utc.subrace_name = "Miner".into();
1084        utc.feats = vec![94, 93, 120];
1085        utc.classes[0].powers = vec![9, 12, 15];
1086        utc.special_abilities = vec![UtcSpecialAbility {
1087            spell_id: 321,
1088            spell_flags: 0b11,
1089            spell_caster_level: 7,
1090        }];
1091        utc.equipment[0].resref = ResRef::new("g_a_class4001").expect("valid test resref");
1092        utc.equipment[0].droppable = false;
1093        utc.inventory[0].resref = ResRef::new("g_w_blstrrfl01").expect("valid test resref");
1094        utc.inventory.push(UtcInventoryItem {
1095            resref: ResRef::new("g_i_progspike01").expect("valid test resref"),
1096            droppable: true,
1097            repos_pos_x: Some(4),
1098            repos_pos_y: Some(0),
1099        });
1100
1101        let encoded = author_utc_to_vec(&utc).expect("encode");
1102        let reparsed = read_utc_from_bytes(&encoded).expect("decode");
1103
1104        assert_eq!(reparsed.tag, "Coorta_Mod");
1105        assert_eq!(reparsed.on_spawn, "k_new_spawn");
1106        assert_eq!(reparsed.skills.persuade, 12);
1107        assert_eq!(reparsed.portrait_resref, "po_pfha01");
1108        assert_eq!(reparsed.save_will, 11);
1109        assert_eq!(reparsed.save_fortitude, 9);
1110        assert_eq!(reparsed.morale, 8);
1111        assert_eq!(reparsed.morale_recovery, 7);
1112        assert_eq!(reparsed.morale_breakpoint, 6);
1113        assert_eq!(reparsed.description.string_ref.raw(), 42_424);
1114        assert_eq!(reparsed.lawfulness, 35);
1115        assert_eq!(reparsed.phenotype_id, 2);
1116        assert_eq!(reparsed.deity, "The Force");
1117        assert_eq!(reparsed.subrace_name, "Miner");
1118        assert_eq!(reparsed.feats, vec![94, 93, 120]);
1119        assert_eq!(reparsed.classes[0].powers, vec![9, 12, 15]);
1120        assert_eq!(reparsed.special_abilities.len(), 1);
1121        assert_eq!(reparsed.special_abilities[0].spell_id, 321);
1122        assert_eq!(reparsed.special_abilities[0].spell_flags, 0b11);
1123        assert_eq!(reparsed.special_abilities[0].spell_caster_level, 7);
1124        assert_eq!(reparsed.equipment[0].resref, "g_a_class4001");
1125        assert!(!reparsed.equipment[0].droppable);
1126        assert_eq!(reparsed.inventory[0].resref, "g_w_blstrrfl01");
1127        assert_eq!(reparsed.inventory.len(), 5);
1128        assert_eq!(reparsed.inventory[4].resref, "g_i_progspike01");
1129        assert!(reparsed.inventory[4].droppable);
1130    }
1131
1132    #[test]
1133    fn no_op_rebuild_preserves_list_order_and_struct_ids() {
1134        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
1135        let rebuilt = utc.to_gff();
1136
1137        assert_eq!(
1138            list_struct_ids(find_list(&rebuilt.root, "FeatList")),
1139            vec![1, 1]
1140        );
1141        assert_eq!(
1142            list_struct_ids(find_list(&rebuilt.root, "Equip_ItemList")),
1143            vec![2, 131_072]
1144        );
1145        assert_eq!(
1146            list_struct_ids(find_list(&rebuilt.root, "ItemList")),
1147            vec![0, 1, 2, 3]
1148        );
1149        assert_eq!(
1150            list_u16_field(find_list(&rebuilt.root, "FeatList"), "Feat"),
1151            vec![93, 94]
1152        );
1153    }
1154
1155    #[test]
1156    fn rejects_non_utc_file_type() {
1157        let gff = Gff::new(*b"UTI ", GffStruct::new(-1));
1158        let err = Utc::from_gff(&gff).expect_err("must fail");
1159        assert!(matches!(err, UtcError::UnsupportedFileType(file_type) if file_type == *b"UTI "));
1160    }
1161
1162    #[test]
1163    fn read_utc_from_reader_matches_bytes_path() {
1164        let mut cursor = Cursor::new(TEST_UTC);
1165        let via_reader = read_utc(&mut cursor).expect("reader parse");
1166        let via_bytes = read_utc_from_bytes(TEST_UTC).expect("bytes parse");
1167        assert_eq!(via_reader.template_resref, via_bytes.template_resref);
1168        assert_eq!(via_reader.classes.len(), via_bytes.classes.len());
1169    }
1170
1171    #[test]
1172    fn a_mistyped_class_list_reads_as_empty() {
1173        let mut root = GffStruct::new(-1);
1174        root.push_field(gff_label!("ClassList"), GffValue::UInt32(7));
1175        let gff = Gff::new(*b"UTC ", root);
1176
1177        let utc = Utc::from_gff(&gff).expect("a mistyped list is not a read failure");
1178
1179        assert!(utc.classes.is_empty());
1180    }
1181
1182    #[test]
1183    fn a_mistyped_spec_ability_list_reads_as_empty() {
1184        let mut root = GffStruct::new(-1);
1185        root.push_field(gff_label!("SpecAbilityList"), GffValue::UInt32(7));
1186        let gff = Gff::new(*b"UTC ", root);
1187
1188        let utc = Utc::from_gff(&gff).expect("a mistyped list is not a read failure");
1189
1190        assert!(utc.special_abilities.is_empty());
1191    }
1192
1193    #[test]
1194    fn write_utc_matches_direct_gff_writer() {
1195        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture parse");
1196        let from_utc = author_utc_to_vec(&utc).expect("utc encode");
1197
1198        let gff = utc.to_gff();
1199        let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
1200        assert_eq!(from_utc, from_gff);
1201    }
1202
1203    fn find_list<'a>(structure: &'a GffStruct, label: &str) -> &'a [GffStruct] {
1204        match structure.field(label) {
1205            Some(GffValue::List(values)) => values.as_slice(),
1206            Some(other) => panic!("field {label} is not a list: {other:?}"),
1207            None => panic!("missing list field {label}"),
1208        }
1209    }
1210
1211    fn list_struct_ids(list: &[GffStruct]) -> Vec<i32> {
1212        list.iter().map(|entry| entry.struct_id).collect::<Vec<_>>()
1213    }
1214
1215    fn list_u16_field(list: &[GffStruct], label: &str) -> Vec<u16> {
1216        list.iter()
1217            .map(|entry| match entry.field(label) {
1218                Some(GffValue::UInt16(value)) => *value,
1219                Some(other) => panic!("field {label} is not UInt16: {other:?}"),
1220                None => panic!("missing field {label}"),
1221            })
1222            .collect::<Vec<_>>()
1223    }
1224
1225    #[test]
1226    fn schema_field_count() {
1227        assert_eq!(Utc::schema().len(), 109);
1228    }
1229
1230    #[test]
1231    fn schema_no_duplicate_labels() {
1232        let mut labels: Vec<&str> = Utc::schema().iter().map(|f| f.label.as_str()).collect();
1233        labels.sort_unstable();
1234        let before = labels.len();
1235        labels.dedup();
1236        assert_eq!(before, labels.len(), "duplicate labels in UTC schema");
1237    }
1238
1239    #[test]
1240    fn schema_lists_carry_their_elements() {
1241        let element_count = |label: &str| {
1242            let field = Utc::schema()
1243                .iter()
1244                .find(|f| f.label.as_str() == label)
1245                .expect("declared");
1246            match field.shape {
1247                // Summed across parts: a schema is a sequence so a shared part
1248                // can be referenced rather than copied.
1249                Shape::List { element, .. } => element.iter().map(|p| p.len()).sum::<usize>(),
1250                other => panic!("{label} is a list, got {other:?}"),
1251            }
1252        };
1253        assert_eq!(element_count("ClassList"), 4);
1254        assert_eq!(element_count("SpecAbilityList"), 3);
1255        assert_eq!(element_count("Equip_ItemList"), 3);
1256        assert_eq!(element_count("ItemList"), 7);
1257        // The three whose elements the model unwraps still declare what the
1258        // file holds: one label each, and none for the childless one.
1259        assert_eq!(element_count("SkillList"), 1);
1260        assert_eq!(element_count("FeatList"), 1);
1261        assert_eq!(element_count("LvlStatList"), 0);
1262    }
1263}