Skip to main content

rakata_generics/
utc.rs

1//! UTC (`.utc`) typed generic wrapper.
2//!
3//! UTC resources are GFF-backed creature templates.
4//!
5//! ## Coverage
6//! - Typed access for creature fields (identity, scripts, core stats, appearance,
7//!   demographics, combat, AI, and movement).
8//! - Typed handling for `SkillList` and `ClassList` / canonical `KnownList0` spell data.
9//! - Typed handling for `SpecAbilityList` (`Spell`, `SpellFlags`, `SpellCasterLevel`).
10//! - Typed handling for `FeatList`, `Equip_ItemList`, and `ItemList`.
11//!
12//! ## Field Layout (simplified)
13//! ```text
14//! UTC root struct
15//! +-- TemplateResRef / Tag / Comment / Conversation
16//! +-- FirstName / LastName               (CExoLocString)
17//! +-- Appearance_Type / Gender / Race / FactionID / WalkRate
18//! +-- Age / StartingPackage / Gold / Experience
19//! +-- Color_Skin / Color_Hair / Color_Tattoo1 / Color_Tattoo2
20//! +-- Appearance_Head / DuplicatingHead / UseBackupHead
21//! +-- AIState / SkillPoints / MovementRate / Invulnerable
22//! +-- Character stats / saves / HP / FP
23//! +-- Script* hooks                      (CResRef)
24//! +-- SkillList                          (List<Struct Rank>)
25//! +-- ClassList                          (List<Struct>)
26//! |   +-- Class / ClassLevel
27//! |   `-- KnownList0                     (List<Struct Spell>)
28//! +-- SpecAbilityList                    (List<Struct Spell/SpellFlags/SpellCasterLevel>)
29//! +-- FeatList                           (List<Struct Feat>)
30//! +-- Equip_ItemList                     (List<Struct EquippedRes/Dropable>)
31//! +-- ItemList                           (List<Struct InventoryRes/...>)
32//! ```
33
34use std::io::{Cursor, Read, Write};
35
36use crate::gff_helpers::{
37    get_bool, get_f32, get_i16, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32,
38    get_u8, upsert_field,
39};
40use crate::shared::{PORTRAIT_ID_USE_RESREF, SCRIPT_SLOT_SEED};
41use rakata_core::{ResRef, StrRef};
42use rakata_formats::{
43    gff_schema::{
44        AbsentDefault, DefaultValue, FieldConstraint, FieldLife, FieldSchema, GffSchema, GffType,
45    },
46    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
47    GffValue,
48};
49use thiserror::Error;
50
51/// Typed UTC model built from/to [`Gff`] data.
52#[derive(Debug, Clone, PartialEq)]
53pub struct Utc {
54    /// Creature template resref (`TemplateResRef`).
55    pub template_resref: ResRef,
56    /// Creature tag (`Tag`).
57    pub tag: String,
58    /// Toolset/comment field (`Comment`). Never read by the K1 engine.
59    pub comment: String,
60    /// Conversation resref (`Conversation`).
61    pub conversation: ResRef,
62    /// Localized first name (`FirstName`).
63    pub first_name: GffLocalizedString,
64    /// Localized last name (`LastName`).
65    pub last_name: GffLocalizedString,
66    /// Age (`Age`).
67    pub age: i32,
68    /// Starting package ID (`StartingPackage`).
69    pub starting_package: u8,
70    /// Gold carried (`Gold`).
71    pub gold: u32,
72    /// Invulnerability flag (`Invulnerable`).
73    pub invulnerable: bool,
74    /// Accumulated experience (`Experience`).
75    pub experience: u32,
76    /// Subrace ID (`SubraceIndex`).
77    pub subrace_id: u8,
78    /// Perception range ID (`PerceptionRange`).
79    pub perception_id: u8,
80    /// Race ID (`Race`).
81    pub race_id: u8,
82    /// Skin color index (`Color_Skin`).
83    pub color_skin: u8,
84    /// Hair color index (`Color_Hair`).
85    pub color_hair: u8,
86    /// First tattoo color index (`Color_Tattoo1`).
87    pub color_tattoo1: u8,
88    /// Second tattoo color index (`Color_Tattoo2`).
89    pub color_tattoo2: u8,
90    /// Head appearance index (`Appearance_Head`). If 0, the engine forces this to 1 at runtime.
91    pub appearance_head: u8,
92    /// Duplicating-head index (`DuplicatingHead`).
93    pub duplicating_head: u8,
94    /// Backup-head flag (`UseBackupHead`).
95    pub use_backup_head: u8,
96    /// Appearance ID (`Appearance_Type`).
97    pub appearance_id: u16,
98    /// Gender ID (`Gender`). Natively clamped by the engine to a maximum of 4.
99    pub gender_id: u8,
100    /// Faction ID (`FactionID`).
101    pub faction_id: u16,
102    /// Walk-rate ID (`WalkRate`).
103    pub walkrate_id: i32,
104    /// AI state flags (`AIState`).
105    pub ai_state: u16,
106    /// Unspent skill points (`SkillPoints`).
107    pub skill_points: u16,
108    /// Movement rate ID (`MovementRate`).
109    pub movement_rate: u8,
110    /// Sound-set ID (`SoundSetFile`).
111    pub soundset_id: u16,
112    /// Portrait ID (`PortraitId`).
113    pub portrait_id: u16,
114    /// Portrait resref override (`Portrait`).
115    pub portrait_resref: ResRef,
116    /// Will save (`SaveWill`). Strictly ignored by the KOTOR engine; reads `willbonus` instead.
117    pub save_will: u8,
118    /// Fortitude save (`SaveFortitude`). Strictly ignored by the KOTOR engine; reads `fortbonus` instead.
119    pub save_fortitude: u8,
120    /// Morale (`Morale`). Never read by the K1 engine.
121    pub morale: u8,
122    /// Morale recovery (`MoraleRecovery`). Never read by the K1 engine.
123    pub morale_recovery: u8,
124    /// Morale breakpoint (`MoraleBreakpoint`). Never read by the K1 engine.
125    pub morale_breakpoint: u8,
126    /// Palette ID (`PaletteID`). Never read by the K1 engine.
127    pub palette_id: u8,
128    /// Body-bag ID (`BodyBag`).
129    pub bodybag_id: u8,
130    /// Body variation (`BodyVariation`). Never read by the K1 engine.
131    pub body_variation: u8,
132    /// Texture variation (`TextureVar`). Never read by the K1 engine.
133    pub texture_variation: u8,
134    /// Good/Evil alignment (`GoodEvil`). Natively clamped by the engine to a maximum of 100.
135    pub alignment: u8,
136    /// Challenge rating (`ChallengeRating`).
137    pub challenge_rating: f32,
138    /// Blind-spot value (`BlindSpot`). Never read by the K1 engine.
139    pub blindspot: f32,
140    /// Multiplier-set ID (`MultiplierSet`). Never read by the K1 engine.
141    pub multiplier_set: u8,
142    /// Natural armor class (`NaturalAC`).
143    pub natural_ac: u8,
144    /// Reflex save bonus (`refbonus`).
145    pub reflex_bonus: i16,
146    /// Will save bonus (`willbonus`).
147    pub willpower_bonus: i16,
148    /// Fortitude save bonus (`fortbonus`).
149    pub fortitude_bonus: i16,
150    /// Strength (`Str`).
151    pub strength: u8,
152    /// Dexterity (`Dex`).
153    pub dexterity: u8,
154    /// Constitution (`Con`).
155    pub constitution: u8,
156    /// Intelligence (`Int`).
157    pub intelligence: u8,
158    /// Wisdom (`Wis`).
159    pub wisdom: u8,
160    /// Charisma (`Cha`).
161    pub charisma: u8,
162    /// Current hit points (`CurrentHitPoints`).
163    pub current_hp: i16,
164    /// Maximum hit points (`MaxHitPoints`).
165    pub max_hp: i16,
166    /// Base hit points (`HitPoints`).
167    pub hp: i16,
168    /// Current force points (`CurrentForce`).
169    pub fp: i16,
170    /// Maximum force points (`ForcePoints`).
171    pub max_fp: i16,
172    /// Non-reorienting flag (`NotReorienting`).
173    pub not_reorienting: bool,
174    /// Party-interact flag (`PartyInteract`).
175    pub party_interact: bool,
176    /// Permanent-death disabled flag (`NoPermDeath`). Never read by the K1 engine.
177    pub no_perm_death: bool,
178    /// Min-1HP flag (`Min1HP`).
179    pub min1_hp: bool,
180    /// Plot flag (`Plot`).
181    pub plot: bool,
182    /// Interruptable flag (`Interruptable`).
183    pub interruptable: bool,
184    /// Player-character flag (`IsPC`).
185    pub is_pc: bool,
186    /// Disarmable flag (`Disarmable`).
187    pub disarmable: bool,
188    /// Ignore creature pathing (`IgnoreCrePath`). Never read by the K1 engine.
189    pub ignore_cre_path: bool,
190    /// Hologram flag (`Hologram`). Never read by the K1 engine.
191    pub hologram: bool,
192    /// Will-not-render flag (`WillNotRender`). Never read by the K1 engine.
193    pub will_not_render: bool,
194    /// Deprecated deity field (`Deity`).
195    pub deity: String,
196    /// Deprecated localized description (`Description`).
197    pub description: GffLocalizedString,
198    /// Deprecated lawfulness alignment (`LawfulChaotic`). Never read by the K1 engine.
199    pub lawfulness: u8,
200    /// Deprecated phenotype (`Phenotype`).
201    pub phenotype_id: i32,
202    /// Deprecated textual subrace (`Subrace`).
203    pub subrace_name: String,
204    /// On-end-dialog script (`ScriptEndDialogu`).
205    pub on_end_dialog: ResRef,
206    /// On-blocked script (`ScriptOnBlocked`).
207    pub on_blocked: ResRef,
208    /// On-heartbeat script (`ScriptHeartbeat`).
209    pub on_heartbeat: ResRef,
210    /// On-notice script (`ScriptOnNotice`).
211    pub on_notice: ResRef,
212    /// On-spell-at script (`ScriptSpellAt`).
213    pub on_spell: ResRef,
214    /// On-attacked script (`ScriptAttacked`).
215    pub on_attacked: ResRef,
216    /// On-damaged script (`ScriptDamaged`).
217    pub on_damaged: ResRef,
218    /// On-disturbed script (`ScriptDisturbed`).
219    pub on_disturbed: ResRef,
220    /// On-end-round script (`ScriptEndRound`).
221    pub on_end_round: ResRef,
222    /// On-dialogue script (`ScriptDialogue`).
223    pub on_dialog: ResRef,
224    /// On-spawn script (`ScriptSpawn`).
225    pub on_spawn: ResRef,
226    /// On-rested script (`ScriptRested`).
227    pub on_rested: ResRef,
228    /// On-death script (`ScriptDeath`).
229    pub on_death: ResRef,
230    /// On-user-defined script (`ScriptUserDefine`).
231    pub on_user_defined: ResRef,
232    /// Skill ranks from `SkillList`.
233    pub skills: UtcSkills,
234    /// Class entries from `ClassList`. Engine ignores classes beyond the second. Duplicate class IDs cause a fatal engine crash.
235    pub classes: Vec<UtcClass>,
236    /// Special abilities from `SpecAbilityList`.
237    pub special_abilities: Vec<UtcSpecialAbility>,
238    /// Feat identifiers from `FeatList` (`Feat`).
239    pub feats: Vec<u16>,
240    /// Equipped-item entries from `Equip_ItemList`.
241    pub equipment: Vec<UtcEquipmentItem>,
242    /// Inventory entries from `ItemList`.
243    pub inventory: Vec<UtcInventoryItem>,
244}
245
246impl Default for Utc {
247    fn default() -> Self {
248        Self {
249            template_resref: ResRef::blank(),
250            tag: String::new(),
251            comment: String::new(),
252            conversation: ResRef::blank(),
253            first_name: GffLocalizedString::new(StrRef::invalid()),
254            last_name: GffLocalizedString::new(StrRef::invalid()),
255            age: 0,
256            starting_package: 0,
257            gold: 0,
258            invulnerable: false,
259            experience: 0,
260            subrace_id: 0,
261            perception_id: 0,
262            race_id: 0,
263            color_skin: 0,
264            color_hair: 0,
265            color_tattoo1: 0,
266            color_tattoo2: 0,
267            appearance_head: 0,
268            duplicating_head: 0,
269            use_backup_head: 0,
270            appearance_id: 0,
271            gender_id: 0,
272            faction_id: 0,
273            walkrate_id: 0,
274            ai_state: 0,
275            skill_points: 0,
276            movement_rate: 0,
277            soundset_id: 0,
278            portrait_id: PORTRAIT_ID_USE_RESREF,
279            portrait_resref: ResRef::blank(),
280            save_will: 0,
281            save_fortitude: 0,
282            morale: 0,
283            morale_recovery: 0,
284            morale_breakpoint: 0,
285            palette_id: 0,
286            bodybag_id: 0,
287            body_variation: 0,
288            texture_variation: 0,
289            alignment: 0,
290            challenge_rating: 0.0,
291            blindspot: 0.0,
292            multiplier_set: 0,
293            natural_ac: 0,
294            reflex_bonus: 0,
295            willpower_bonus: 0,
296            fortitude_bonus: 0,
297            strength: 0,
298            dexterity: 0,
299            constitution: 0,
300            intelligence: 0,
301            wisdom: 0,
302            charisma: 0,
303            current_hp: 0,
304            max_hp: 0,
305            hp: 0,
306            fp: 0,
307            max_fp: 0,
308            not_reorienting: false,
309            party_interact: false,
310            no_perm_death: false,
311            min1_hp: false,
312            plot: false,
313            interruptable: false,
314            is_pc: false,
315            disarmable: false,
316            ignore_cre_path: false,
317            hologram: false,
318            will_not_render: false,
319            deity: String::new(),
320            description: GffLocalizedString::new(StrRef::invalid()),
321            lawfulness: 0,
322            phenotype_id: 0,
323            subrace_name: String::new(),
324            on_end_dialog: SCRIPT_SLOT_SEED,
325            on_blocked: SCRIPT_SLOT_SEED,
326            on_heartbeat: SCRIPT_SLOT_SEED,
327            on_notice: SCRIPT_SLOT_SEED,
328            on_spell: SCRIPT_SLOT_SEED,
329            on_attacked: SCRIPT_SLOT_SEED,
330            on_damaged: SCRIPT_SLOT_SEED,
331            on_disturbed: SCRIPT_SLOT_SEED,
332            on_end_round: SCRIPT_SLOT_SEED,
333            on_dialog: SCRIPT_SLOT_SEED,
334            on_spawn: SCRIPT_SLOT_SEED,
335            on_rested: SCRIPT_SLOT_SEED,
336            on_death: SCRIPT_SLOT_SEED,
337            on_user_defined: SCRIPT_SLOT_SEED,
338            skills: UtcSkills::default(),
339            classes: Vec::new(),
340            special_abilities: Vec::new(),
341            feats: Vec::new(),
342            equipment: Vec::new(),
343            inventory: Vec::new(),
344        }
345    }
346}
347
348impl Utc {
349    /// Creates an empty UTC value.
350    pub fn new() -> Self {
351        Self::default()
352    }
353
354    /// Builds typed UTC data from a parsed GFF container.
355    pub fn from_gff(gff: &Gff) -> Result<Self, UtcError> {
356        if gff.file_type != *b"UTC " && gff.file_type != *b"GFF " {
357            return Err(UtcError::UnsupportedFileType(gff.file_type));
358        }
359
360        let root = &gff.root;
361
362        let skills = match root.field("SkillList") {
363            Some(GffValue::List(skill_structs)) => UtcSkills::from_list(skill_structs),
364            Some(_) => {
365                return Err(UtcError::TypeMismatch {
366                    field: "SkillList",
367                    expected: "List",
368                });
369            }
370            None => UtcSkills::default(),
371        };
372
373        let classes = match root.field("ClassList") {
374            Some(GffValue::List(class_structs)) => class_structs
375                .iter()
376                .map(UtcClass::from_struct)
377                .collect::<Result<Vec<_>, _>>()?,
378            Some(_) => {
379                return Err(UtcError::TypeMismatch {
380                    field: "ClassList",
381                    expected: "List",
382                });
383            }
384            None => Vec::new(),
385        };
386
387        let special_abilities = match root.field("SpecAbilityList") {
388            Some(GffValue::List(ability_structs)) => ability_structs
389                .iter()
390                .map(UtcSpecialAbility::from_struct)
391                .collect::<Vec<_>>(),
392            Some(_) => {
393                return Err(UtcError::TypeMismatch {
394                    field: "SpecAbilityList",
395                    expected: "List",
396                });
397            }
398            None => Vec::new(),
399        };
400
401        let feats = match root.field("FeatList") {
402            Some(GffValue::List(feat_structs)) => feat_structs
403                .iter()
404                .map(|feat_struct| get_u16(feat_struct, "Feat").unwrap_or(0))
405                .collect::<Vec<_>>(),
406            Some(_) => {
407                return Err(UtcError::TypeMismatch {
408                    field: "FeatList",
409                    expected: "List",
410                });
411            }
412            None => Vec::new(),
413        };
414
415        let equipment = match root.field("Equip_ItemList") {
416            Some(GffValue::List(equipment_structs)) => equipment_structs
417                .iter()
418                .map(UtcEquipmentItem::from_struct)
419                .collect::<Vec<_>>(),
420            Some(_) => {
421                return Err(UtcError::TypeMismatch {
422                    field: "Equip_ItemList",
423                    expected: "List",
424                });
425            }
426            None => Vec::new(),
427        };
428
429        let inventory = match root.field("ItemList") {
430            Some(GffValue::List(inventory_structs)) => inventory_structs
431                .iter()
432                .map(UtcInventoryItem::from_struct)
433                .collect::<Vec<_>>(),
434            Some(_) => {
435                return Err(UtcError::TypeMismatch {
436                    field: "ItemList",
437                    expected: "List",
438                });
439            }
440            None => Vec::new(),
441        };
442
443        Ok(Self {
444            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
445            tag: get_string(root, "Tag").unwrap_or_default(),
446            comment: get_string(root, "Comment").unwrap_or_default(),
447            conversation: get_resref(root, "Conversation").unwrap_or_default(),
448            first_name: get_locstring(root, "FirstName")
449                .cloned()
450                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
451            last_name: get_locstring(root, "LastName")
452                .cloned()
453                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
454            age: get_i32(root, "Age").unwrap_or(0),
455            starting_package: get_u8(root, "StartingPackage").unwrap_or(0),
456            gold: get_u32(root, "Gold").unwrap_or(0),
457            invulnerable: get_bool(root, "Invulnerable").unwrap_or(false),
458            experience: get_u32(root, "Experience").unwrap_or(0),
459            subrace_id: get_u8(root, "SubraceIndex").unwrap_or(0),
460            perception_id: get_u8(root, "PerceptionRange").unwrap_or(0),
461            race_id: get_u8(root, "Race").unwrap_or(0),
462            color_skin: get_u8(root, "Color_Skin").unwrap_or(0),
463            color_hair: get_u8(root, "Color_Hair").unwrap_or(0),
464            color_tattoo1: get_u8(root, "Color_Tattoo1").unwrap_or(0),
465            color_tattoo2: get_u8(root, "Color_Tattoo2").unwrap_or(0),
466            appearance_head: get_u8(root, "Appearance_Head").unwrap_or(0),
467            duplicating_head: get_u8(root, "DuplicatingHead").unwrap_or(0),
468            use_backup_head: get_u8(root, "UseBackupHead").unwrap_or(0),
469            appearance_id: get_u16(root, "Appearance_Type").unwrap_or(0),
470            gender_id: get_u8(root, "Gender").unwrap_or(0),
471            faction_id: get_u16(root, "FactionID").unwrap_or(0),
472            walkrate_id: get_i32(root, "WalkRate").unwrap_or(0),
473            ai_state: get_u16(root, "AIState")
474                .or_else(|| get_i32(root, "AIState").and_then(|v| u16::try_from(v).ok()))
475                .unwrap_or(0),
476            skill_points: get_u16(root, "SkillPoints").unwrap_or(0),
477            // utc.md: `MovementRate` falls back to the `WalkRate` label, the
478            // same legacy-label shape as `Plot`/`Invulnerable`. Reading `0`
479            // for an absent field claimed a real `creaturespeed.2da` row.
480            // What the pair resolves to when NEITHER label is present is not
481            // documented, so that fallback stays unaudited.
482            movement_rate: get_u8(root, "MovementRate")
483                .or_else(|| get_i32(root, "WalkRate").and_then(|v| u8::try_from(v).ok()))
484                .unwrap_or(0),
485            soundset_id: get_u16(root, "SoundSetFile").unwrap_or(0),
486            portrait_id: get_u16(root, "PortraitId").unwrap_or(PORTRAIT_ID_USE_RESREF),
487            portrait_resref: get_resref(root, "Portrait").unwrap_or_default(),
488            save_will: get_u8(root, "SaveWill").unwrap_or(0),
489            save_fortitude: get_u8(root, "SaveFortitude").unwrap_or(0),
490            morale: get_u8(root, "Morale").unwrap_or(0),
491            morale_recovery: get_u8(root, "MoraleRecovery").unwrap_or(0),
492            morale_breakpoint: get_u8(root, "MoraleBreakpoint").unwrap_or(0),
493            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
494            bodybag_id: get_u8(root, "BodyBag").unwrap_or(0),
495            body_variation: get_u8(root, "BodyVariation").unwrap_or(0),
496            texture_variation: get_u8(root, "TextureVar").unwrap_or(0),
497            alignment: get_u8(root, "GoodEvil").unwrap_or(0),
498            challenge_rating: get_f32(root, "ChallengeRating").unwrap_or(0.0),
499            blindspot: get_f32(root, "BlindSpot").unwrap_or(0.0),
500            multiplier_set: get_u8(root, "MultiplierSet").unwrap_or(0),
501            natural_ac: get_u8(root, "NaturalAC").unwrap_or(0),
502            reflex_bonus: get_i16(root, "refbonus").unwrap_or(0),
503            willpower_bonus: get_i16(root, "willbonus").unwrap_or(0),
504            fortitude_bonus: get_i16(root, "fortbonus").unwrap_or(0),
505            strength: get_u8(root, "Str").unwrap_or(0),
506            dexterity: get_u8(root, "Dex").unwrap_or(0),
507            constitution: get_u8(root, "Con").unwrap_or(0),
508            intelligence: get_u8(root, "Int").unwrap_or(0),
509            wisdom: get_u8(root, "Wis").unwrap_or(0),
510            charisma: get_u8(root, "Cha").unwrap_or(0),
511            current_hp: get_i16(root, "CurrentHitPoints").unwrap_or(0),
512            max_hp: get_i16(root, "MaxHitPoints").unwrap_or(0),
513            hp: get_i16(root, "HitPoints").unwrap_or(0),
514            fp: get_i16(root, "CurrentForce").unwrap_or(0),
515            max_fp: get_i16(root, "ForcePoints").unwrap_or(0),
516            not_reorienting: get_bool(root, "NotReorienting").unwrap_or(false),
517            party_interact: get_bool(root, "PartyInteract").unwrap_or(false),
518            no_perm_death: get_bool(root, "NoPermDeath").unwrap_or(false),
519            min1_hp: get_bool(root, "Min1HP").unwrap_or(false),
520            plot: get_bool(root, "Plot").unwrap_or(false),
521            interruptable: get_bool(root, "Interruptable").unwrap_or(false),
522            is_pc: get_bool(root, "IsPC").unwrap_or(false),
523            disarmable: get_bool(root, "Disarmable").unwrap_or(false),
524            ignore_cre_path: get_bool(root, "IgnoreCrePath").unwrap_or(false),
525            hologram: get_bool(root, "Hologram").unwrap_or(false),
526            will_not_render: get_bool(root, "WillNotRender").unwrap_or(false),
527            deity: get_string(root, "Deity").unwrap_or_default(),
528            description: get_locstring(root, "Description")
529                .cloned()
530                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
531            lawfulness: get_u8(root, "LawfulChaotic").unwrap_or(0),
532            phenotype_id: get_i32(root, "Phenotype").unwrap_or(0),
533            subrace_name: get_string(root, "Subrace").unwrap_or_default(),
534            on_end_dialog: get_resref(root, "ScriptEndDialogu").unwrap_or(SCRIPT_SLOT_SEED),
535            on_blocked: get_resref(root, "ScriptOnBlocked").unwrap_or(SCRIPT_SLOT_SEED),
536            on_heartbeat: get_resref(root, "ScriptHeartbeat").unwrap_or(SCRIPT_SLOT_SEED),
537            on_notice: get_resref(root, "ScriptOnNotice").unwrap_or(SCRIPT_SLOT_SEED),
538            on_spell: get_resref(root, "ScriptSpellAt").unwrap_or(SCRIPT_SLOT_SEED),
539            on_attacked: get_resref(root, "ScriptAttacked").unwrap_or(SCRIPT_SLOT_SEED),
540            on_damaged: get_resref(root, "ScriptDamaged").unwrap_or(SCRIPT_SLOT_SEED),
541            on_disturbed: get_resref(root, "ScriptDisturbed").unwrap_or(SCRIPT_SLOT_SEED),
542            on_end_round: get_resref(root, "ScriptEndRound").unwrap_or(SCRIPT_SLOT_SEED),
543            on_dialog: get_resref(root, "ScriptDialogue").unwrap_or(SCRIPT_SLOT_SEED),
544            on_spawn: get_resref(root, "ScriptSpawn").unwrap_or(SCRIPT_SLOT_SEED),
545            on_rested: get_resref(root, "ScriptRested").unwrap_or(SCRIPT_SLOT_SEED),
546            on_death: get_resref(root, "ScriptDeath").unwrap_or(SCRIPT_SLOT_SEED),
547            on_user_defined: get_resref(root, "ScriptUserDefine").unwrap_or(SCRIPT_SLOT_SEED),
548            skills,
549            classes,
550            special_abilities,
551            feats,
552            equipment,
553            inventory,
554        })
555    }
556
557    /// Converts this typed UTC value into a GFF container.
558    pub fn to_gff(&self) -> Gff {
559        let mut root = GffStruct::new(-1);
560
561        upsert_field(
562            &mut root,
563            "TemplateResRef",
564            GffValue::ResRef(self.template_resref),
565        );
566        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
567        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
568        upsert_field(
569            &mut root,
570            "Conversation",
571            GffValue::ResRef(self.conversation),
572        );
573        upsert_field(
574            &mut root,
575            "FirstName",
576            GffValue::LocalizedString(self.first_name.clone()),
577        );
578        upsert_field(
579            &mut root,
580            "LastName",
581            GffValue::LocalizedString(self.last_name.clone()),
582        );
583
584        upsert_field(&mut root, "Age", GffValue::Int32(self.age));
585        upsert_field(
586            &mut root,
587            "StartingPackage",
588            GffValue::UInt8(self.starting_package),
589        );
590        upsert_field(&mut root, "Gold", GffValue::UInt32(self.gold));
591        upsert_field(
592            &mut root,
593            "Invulnerable",
594            GffValue::UInt8(u8::from(self.invulnerable)),
595        );
596        upsert_field(&mut root, "Experience", GffValue::UInt32(self.experience));
597
598        upsert_field(&mut root, "SubraceIndex", GffValue::UInt8(self.subrace_id));
599        upsert_field(
600            &mut root,
601            "PerceptionRange",
602            GffValue::UInt8(self.perception_id),
603        );
604        upsert_field(&mut root, "Race", GffValue::UInt8(self.race_id));
605        upsert_field(&mut root, "Color_Skin", GffValue::UInt8(self.color_skin));
606        upsert_field(&mut root, "Color_Hair", GffValue::UInt8(self.color_hair));
607        upsert_field(
608            &mut root,
609            "Color_Tattoo1",
610            GffValue::UInt8(self.color_tattoo1),
611        );
612        upsert_field(
613            &mut root,
614            "Color_Tattoo2",
615            GffValue::UInt8(self.color_tattoo2),
616        );
617        upsert_field(
618            &mut root,
619            "Appearance_Head",
620            GffValue::UInt8(self.appearance_head),
621        );
622        upsert_field(
623            &mut root,
624            "DuplicatingHead",
625            GffValue::UInt8(self.duplicating_head),
626        );
627        upsert_field(
628            &mut root,
629            "UseBackupHead",
630            GffValue::UInt8(self.use_backup_head),
631        );
632        upsert_field(
633            &mut root,
634            "Appearance_Type",
635            GffValue::UInt16(self.appearance_id),
636        );
637        upsert_field(&mut root, "Gender", GffValue::UInt8(self.gender_id));
638        upsert_field(&mut root, "FactionID", GffValue::UInt16(self.faction_id));
639        upsert_field(&mut root, "WalkRate", GffValue::Int32(self.walkrate_id));
640        // Engine reads AIState as INT but stores only ushort range.
641        upsert_field(
642            &mut root,
643            "AIState",
644            GffValue::Int32(i32::from(self.ai_state)),
645        );
646        upsert_field(
647            &mut root,
648            "SkillPoints",
649            GffValue::UInt16(self.skill_points),
650        );
651        upsert_field(
652            &mut root,
653            "MovementRate",
654            GffValue::UInt8(self.movement_rate),
655        );
656        upsert_field(
657            &mut root,
658            "SoundSetFile",
659            GffValue::UInt16(self.soundset_id),
660        );
661        upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
662        upsert_field(
663            &mut root,
664            "Portrait",
665            GffValue::ResRef(self.portrait_resref),
666        );
667        upsert_field(&mut root, "SaveWill", GffValue::UInt8(self.save_will));
668        upsert_field(
669            &mut root,
670            "SaveFortitude",
671            GffValue::UInt8(self.save_fortitude),
672        );
673        upsert_field(&mut root, "Morale", GffValue::UInt8(self.morale));
674        upsert_field(
675            &mut root,
676            "MoraleRecovery",
677            GffValue::UInt8(self.morale_recovery),
678        );
679        upsert_field(
680            &mut root,
681            "MoraleBreakpoint",
682            GffValue::UInt8(self.morale_breakpoint),
683        );
684        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
685        upsert_field(&mut root, "BodyBag", GffValue::UInt8(self.bodybag_id));
686        upsert_field(
687            &mut root,
688            "BodyVariation",
689            GffValue::UInt8(self.body_variation),
690        );
691        upsert_field(
692            &mut root,
693            "TextureVar",
694            GffValue::UInt8(self.texture_variation),
695        );
696
697        upsert_field(&mut root, "GoodEvil", GffValue::UInt8(self.alignment));
698        upsert_field(
699            &mut root,
700            "ChallengeRating",
701            GffValue::Single(self.challenge_rating),
702        );
703        upsert_field(&mut root, "BlindSpot", GffValue::Single(self.blindspot));
704        upsert_field(
705            &mut root,
706            "MultiplierSet",
707            GffValue::UInt8(self.multiplier_set),
708        );
709        upsert_field(&mut root, "NaturalAC", GffValue::UInt8(self.natural_ac));
710        upsert_field(&mut root, "refbonus", GffValue::Int16(self.reflex_bonus));
711        upsert_field(
712            &mut root,
713            "willbonus",
714            GffValue::Int16(self.willpower_bonus),
715        );
716        upsert_field(
717            &mut root,
718            "fortbonus",
719            GffValue::Int16(self.fortitude_bonus),
720        );
721
722        upsert_field(&mut root, "Str", GffValue::UInt8(self.strength));
723        upsert_field(&mut root, "Dex", GffValue::UInt8(self.dexterity));
724        upsert_field(&mut root, "Con", GffValue::UInt8(self.constitution));
725        upsert_field(&mut root, "Int", GffValue::UInt8(self.intelligence));
726        upsert_field(&mut root, "Wis", GffValue::UInt8(self.wisdom));
727        upsert_field(&mut root, "Cha", GffValue::UInt8(self.charisma));
728
729        upsert_field(
730            &mut root,
731            "CurrentHitPoints",
732            GffValue::Int16(self.current_hp),
733        );
734        upsert_field(&mut root, "MaxHitPoints", GffValue::Int16(self.max_hp));
735        upsert_field(&mut root, "HitPoints", GffValue::Int16(self.hp));
736        upsert_field(&mut root, "CurrentForce", GffValue::Int16(self.fp));
737        upsert_field(&mut root, "ForcePoints", GffValue::Int16(self.max_fp));
738
739        upsert_field(
740            &mut root,
741            "NotReorienting",
742            GffValue::UInt8(u8::from(self.not_reorienting)),
743        );
744        upsert_field(
745            &mut root,
746            "PartyInteract",
747            GffValue::UInt8(u8::from(self.party_interact)),
748        );
749        upsert_field(
750            &mut root,
751            "NoPermDeath",
752            GffValue::UInt8(u8::from(self.no_perm_death)),
753        );
754        upsert_field(&mut root, "Min1HP", GffValue::UInt8(u8::from(self.min1_hp)));
755        upsert_field(&mut root, "Plot", GffValue::UInt8(u8::from(self.plot)));
756        upsert_field(
757            &mut root,
758            "Interruptable",
759            GffValue::UInt8(u8::from(self.interruptable)),
760        );
761        upsert_field(&mut root, "IsPC", GffValue::UInt8(u8::from(self.is_pc)));
762        upsert_field(
763            &mut root,
764            "Disarmable",
765            GffValue::UInt8(u8::from(self.disarmable)),
766        );
767        upsert_field(
768            &mut root,
769            "IgnoreCrePath",
770            GffValue::UInt8(u8::from(self.ignore_cre_path)),
771        );
772        upsert_field(
773            &mut root,
774            "Hologram",
775            GffValue::UInt8(u8::from(self.hologram)),
776        );
777        upsert_field(
778            &mut root,
779            "WillNotRender",
780            GffValue::UInt8(u8::from(self.will_not_render)),
781        );
782        upsert_field(&mut root, "Deity", GffValue::String(self.deity.clone()));
783        upsert_field(
784            &mut root,
785            "Description",
786            GffValue::LocalizedString(self.description.clone()),
787        );
788        upsert_field(&mut root, "LawfulChaotic", GffValue::UInt8(self.lawfulness));
789        upsert_field(&mut root, "Phenotype", GffValue::Int32(self.phenotype_id));
790        upsert_field(
791            &mut root,
792            "Subrace",
793            GffValue::String(self.subrace_name.clone()),
794        );
795
796        upsert_field(
797            &mut root,
798            "ScriptEndDialogu",
799            GffValue::ResRef(self.on_end_dialog),
800        );
801        upsert_field(
802            &mut root,
803            "ScriptOnBlocked",
804            GffValue::ResRef(self.on_blocked),
805        );
806        upsert_field(
807            &mut root,
808            "ScriptHeartbeat",
809            GffValue::ResRef(self.on_heartbeat),
810        );
811        upsert_field(
812            &mut root,
813            "ScriptOnNotice",
814            GffValue::ResRef(self.on_notice),
815        );
816        upsert_field(&mut root, "ScriptSpellAt", GffValue::ResRef(self.on_spell));
817        upsert_field(
818            &mut root,
819            "ScriptAttacked",
820            GffValue::ResRef(self.on_attacked),
821        );
822        upsert_field(
823            &mut root,
824            "ScriptDamaged",
825            GffValue::ResRef(self.on_damaged),
826        );
827        upsert_field(
828            &mut root,
829            "ScriptDisturbed",
830            GffValue::ResRef(self.on_disturbed),
831        );
832        upsert_field(
833            &mut root,
834            "ScriptEndRound",
835            GffValue::ResRef(self.on_end_round),
836        );
837        upsert_field(
838            &mut root,
839            "ScriptDialogue",
840            GffValue::ResRef(self.on_dialog),
841        );
842        upsert_field(&mut root, "ScriptSpawn", GffValue::ResRef(self.on_spawn));
843        upsert_field(&mut root, "ScriptRested", GffValue::ResRef(self.on_rested));
844        upsert_field(&mut root, "ScriptDeath", GffValue::ResRef(self.on_death));
845        upsert_field(
846            &mut root,
847            "ScriptUserDefine",
848            GffValue::ResRef(self.on_user_defined),
849        );
850
851        upsert_field(
852            &mut root,
853            "SkillList",
854            GffValue::List(self.skills.to_list()),
855        );
856
857        let class_structs = self
858            .classes
859            .iter()
860            .map(UtcClass::to_struct)
861            .collect::<Vec<GffStruct>>();
862        upsert_field(&mut root, "ClassList", GffValue::List(class_structs));
863
864        let special_ability_structs = self
865            .special_abilities
866            .iter()
867            .map(UtcSpecialAbility::to_struct)
868            .collect::<Vec<GffStruct>>();
869        upsert_field(
870            &mut root,
871            "SpecAbilityList",
872            GffValue::List(special_ability_structs),
873        );
874
875        upsert_field(
876            &mut root,
877            "FeatList",
878            GffValue::List(write_feat_structs(&self.feats, None)),
879        );
880
881        let equipment_structs = self
882            .equipment
883            .iter()
884            .map(UtcEquipmentItem::to_struct)
885            .collect::<Vec<GffStruct>>();
886        upsert_field(
887            &mut root,
888            "Equip_ItemList",
889            GffValue::List(equipment_structs),
890        );
891
892        let inventory_structs = self
893            .inventory
894            .iter()
895            .map(UtcInventoryItem::to_struct)
896            .collect::<Vec<GffStruct>>();
897        upsert_field(&mut root, "ItemList", GffValue::List(inventory_structs));
898
899        Gff::new(*b"UTC ", root)
900    }
901}
902
903/// Skill ranks mirrored from UTC `SkillList` index ordering.
904#[derive(Debug, Clone, PartialEq, Default)]
905pub struct UtcSkills {
906    /// Skill index `0` (`Computer Use`).
907    pub computer_use: u8,
908    /// Skill index `1` (`Demolitions`).
909    pub demolitions: u8,
910    /// Skill index `2` (`Stealth`).
911    pub stealth: u8,
912    /// Skill index `3` (`Awareness`).
913    pub awareness: u8,
914    /// Skill index `4` (`Persuade`).
915    pub persuade: u8,
916    /// Skill index `5` (`Repair`).
917    pub repair: u8,
918    /// Skill index `6` (`Security`).
919    pub security: u8,
920    /// Skill index `7` (`Treat Injury`).
921    pub treat_injury: u8,
922}
923
924impl UtcSkills {
925    fn from_list(list: &[GffStruct]) -> Self {
926        Self {
927            computer_use: read_skill_rank(list, 0),
928            demolitions: read_skill_rank(list, 1),
929            stealth: read_skill_rank(list, 2),
930            awareness: read_skill_rank(list, 3),
931            persuade: read_skill_rank(list, 4),
932            repair: read_skill_rank(list, 5),
933            security: read_skill_rank(list, 6),
934            treat_injury: read_skill_rank(list, 7),
935        }
936    }
937
938    fn to_list(&self) -> Vec<GffStruct> {
939        let mut list = default_skill_structs();
940
941        write_skill_rank(&mut list[0], self.computer_use);
942        write_skill_rank(&mut list[1], self.demolitions);
943        write_skill_rank(&mut list[2], self.stealth);
944        write_skill_rank(&mut list[3], self.awareness);
945        write_skill_rank(&mut list[4], self.persuade);
946        write_skill_rank(&mut list[5], self.repair);
947        write_skill_rank(&mut list[6], self.security);
948        write_skill_rank(&mut list[7], self.treat_injury);
949
950        list
951    }
952}
953
954/// One UTC class entry in `ClassList`.
955#[derive(Debug, Clone, PartialEq)]
956pub struct UtcClass {
957    /// Class identifier (`Class`).
958    pub class_id: i32,
959    /// Class level (`ClassLevel`).
960    pub class_level: i16,
961    /// Spell/power identifiers from `KnownList0` (`Spell`).
962    pub powers: Vec<u16>,
963}
964
965impl UtcClass {
966    fn from_struct(structure: &GffStruct) -> Result<Self, UtcError> {
967        let powers = parse_known_list(structure, "KnownList0")?;
968
969        Ok(Self {
970            class_id: get_i32(structure, "Class").unwrap_or(0),
971            class_level: get_i16(structure, "ClassLevel").unwrap_or(0),
972            powers,
973        })
974    }
975
976    fn to_struct(&self) -> GffStruct {
977        let mut structure = GffStruct::new(2);
978
979        upsert_field(&mut structure, "Class", GffValue::Int32(self.class_id));
980        upsert_field(
981            &mut structure,
982            "ClassLevel",
983            GffValue::Int16(self.class_level),
984        );
985        write_known_list(&mut structure, "KnownList0", &self.powers);
986        structure
987    }
988}
989
990/// One UTC special-ability entry from `SpecAbilityList`.
991#[derive(Debug, Clone, PartialEq)]
992pub struct UtcSpecialAbility {
993    /// Ability spell identifier (`Spell`).
994    pub spell_id: u16,
995    /// Ability behavior flags (`SpellFlags`).
996    pub spell_flags: u8,
997    /// Ability caster level (`SpellCasterLevel`).
998    pub spell_caster_level: u8,
999}
1000
1001impl UtcSpecialAbility {
1002    fn from_struct(structure: &GffStruct) -> Self {
1003        Self {
1004            spell_id: get_u16(structure, "Spell").unwrap_or(0),
1005            spell_flags: get_u8(structure, "SpellFlags").unwrap_or(0),
1006            spell_caster_level: get_u8(structure, "SpellCasterLevel").unwrap_or(0),
1007        }
1008    }
1009
1010    fn to_struct(&self) -> GffStruct {
1011        let mut structure = GffStruct::new(4);
1012
1013        upsert_field(&mut structure, "Spell", GffValue::UInt16(self.spell_id));
1014        upsert_field(
1015            &mut structure,
1016            "SpellFlags",
1017            GffValue::UInt8(self.spell_flags),
1018        );
1019        upsert_field(
1020            &mut structure,
1021            "SpellCasterLevel",
1022            GffValue::UInt8(self.spell_caster_level),
1023        );
1024        structure
1025    }
1026}
1027
1028fn parse_known_list(structure: &GffStruct, label: &'static str) -> Result<Vec<u16>, UtcError> {
1029    match structure.field(label) {
1030        Some(GffValue::List(power_structs)) => Ok(power_structs
1031            .iter()
1032            .map(|power_struct| get_u16(power_struct, "Spell").unwrap_or(0))
1033            .collect::<Vec<_>>()),
1034        Some(_) => Err(UtcError::TypeMismatch {
1035            field: label,
1036            expected: "List",
1037        }),
1038        None => Ok(Vec::new()),
1039    }
1040}
1041
1042fn write_known_list(structure: &mut GffStruct, label: &'static str, powers: &[u16]) {
1043    let power_structs = powers
1044        .iter()
1045        .copied()
1046        .map(|spell| {
1047            let mut s = GffStruct::new(3);
1048            upsert_field(&mut s, "Spell", GffValue::UInt16(spell));
1049            upsert_field(&mut s, "SpellFlags", GffValue::UInt8(1));
1050            upsert_field(&mut s, "SpellMetaMagic", GffValue::UInt8(0));
1051            s
1052        })
1053        .collect::<Vec<_>>();
1054
1055    upsert_field(structure, label, GffValue::List(power_structs));
1056}
1057
1058/// One equipped item entry from `Equip_ItemList`.
1059#[derive(Debug, Clone, PartialEq)]
1060pub struct UtcEquipmentItem {
1061    /// Equipment slot identifier from list struct id.
1062    pub slot_id: i32,
1063    /// Equipped item resref (`EquippedRes`).
1064    pub resref: ResRef,
1065    /// Drop flag (`Dropable`).
1066    pub droppable: bool,
1067}
1068
1069impl UtcEquipmentItem {
1070    /// Creates a new equipped-item entry.
1071    pub fn new(slot_id: i32, resref: ResRef) -> Self {
1072        Self {
1073            slot_id,
1074            resref,
1075            droppable: false,
1076        }
1077    }
1078
1079    fn from_struct(structure: &GffStruct) -> Self {
1080        Self {
1081            slot_id: structure.struct_id,
1082            resref: get_resref(structure, "EquippedRes").unwrap_or_default(),
1083            droppable: get_bool(structure, "Dropable").unwrap_or(false),
1084        }
1085    }
1086
1087    fn to_struct(&self) -> GffStruct {
1088        let mut structure = GffStruct::new(self.slot_id);
1089
1090        upsert_field(&mut structure, "EquippedRes", GffValue::ResRef(self.resref));
1091        if self.droppable {
1092            upsert_field(&mut structure, "Dropable", GffValue::UInt8(1));
1093        }
1094
1095        structure
1096    }
1097}
1098
1099/// One inventory item entry from `ItemList`.
1100#[derive(Debug, Clone, PartialEq)]
1101pub struct UtcInventoryItem {
1102    /// Inventory entry identifier from list struct id.
1103    pub entry_id: i32,
1104    /// Inventory item resref (`InventoryRes`).
1105    pub resref: ResRef,
1106    /// Drop flag (`Dropable`).
1107    pub droppable: bool,
1108    /// Optional inventory grid X (`Repos_PosX`).
1109    pub repos_pos_x: Option<u16>,
1110    /// Optional inventory grid Y (`Repos_Posy`).
1111    pub repos_pos_y: Option<u16>,
1112}
1113
1114impl UtcInventoryItem {
1115    /// Creates a new inventory-item entry.
1116    pub fn new(entry_id: i32, resref: ResRef) -> Self {
1117        Self {
1118            entry_id,
1119            resref,
1120            droppable: false,
1121            repos_pos_x: None,
1122            repos_pos_y: None,
1123        }
1124    }
1125
1126    fn from_struct(structure: &GffStruct) -> Self {
1127        Self {
1128            entry_id: structure.struct_id,
1129            resref: get_resref(structure, "InventoryRes").unwrap_or_default(),
1130            droppable: get_bool(structure, "Dropable").unwrap_or(false),
1131            repos_pos_x: get_u16(structure, "Repos_PosX"),
1132            repos_pos_y: get_u16(structure, "Repos_Posy"),
1133        }
1134    }
1135
1136    fn to_struct(&self) -> GffStruct {
1137        let mut structure = GffStruct::new(self.entry_id);
1138
1139        upsert_field(
1140            &mut structure,
1141            "InventoryRes",
1142            GffValue::ResRef(self.resref),
1143        );
1144        if self.droppable {
1145            upsert_field(&mut structure, "Dropable", GffValue::UInt8(1));
1146        }
1147
1148        if let Some(value) = self.repos_pos_x {
1149            upsert_field(&mut structure, "Repos_PosX", GffValue::UInt16(value));
1150        }
1151        if let Some(value) = self.repos_pos_y {
1152            upsert_field(&mut structure, "Repos_Posy", GffValue::UInt16(value));
1153        }
1154
1155        structure
1156    }
1157}
1158
1159/// Errors produced while reading or writing typed UTC data.
1160#[derive(Debug, Error)]
1161pub enum UtcError {
1162    /// Source file type is not supported by this parser.
1163    #[error("unsupported UTC file type: {0:?}")]
1164    UnsupportedFileType([u8; 4]),
1165    /// A required container field had an unexpected runtime type.
1166    #[error("UTC field `{field}` has incompatible type (expected {expected})")]
1167    TypeMismatch {
1168        /// Field label where mismatch occurred.
1169        field: &'static str,
1170        /// Expected runtime value kind.
1171        expected: &'static str,
1172    },
1173    /// Underlying GFF parser/writer error.
1174    #[error(transparent)]
1175    Gff(#[from] GffBinaryError),
1176}
1177
1178/// Reads typed UTC data from a reader at the current stream position.
1179#[cfg_attr(
1180    feature = "tracing",
1181    tracing::instrument(level = "debug", skip(reader))
1182)]
1183pub fn read_utc<R: Read>(reader: &mut R) -> Result<Utc, UtcError> {
1184    let gff = read_gff(reader)?;
1185    Utc::from_gff(&gff)
1186}
1187
1188/// Reads typed UTC data directly from bytes.
1189#[cfg_attr(
1190    feature = "tracing",
1191    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
1192)]
1193pub fn read_utc_from_bytes(bytes: &[u8]) -> Result<Utc, UtcError> {
1194    let gff = read_gff_from_bytes(bytes)?;
1195    Utc::from_gff(&gff)
1196}
1197
1198/// Writes typed UTC data to an output writer.
1199#[cfg_attr(
1200    feature = "tracing",
1201    tracing::instrument(level = "debug", skip(writer, utc))
1202)]
1203pub fn write_utc<W: Write>(writer: &mut W, utc: &Utc) -> Result<(), UtcError> {
1204    let gff = utc.to_gff();
1205    write_gff(writer, &gff)?;
1206    Ok(())
1207}
1208
1209/// Serializes typed UTC data into a byte vector.
1210#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utc)))]
1211pub fn write_utc_to_vec(utc: &Utc) -> Result<Vec<u8>, UtcError> {
1212    let mut cursor = Cursor::new(Vec::new());
1213    write_utc(&mut cursor, utc)?;
1214    Ok(cursor.into_inner())
1215}
1216
1217fn write_feat_structs(feats: &[u16], existing: Option<&[GffStruct]>) -> Vec<GffStruct> {
1218    let mut feat_structs = existing.map_or_else(Vec::new, <[GffStruct]>::to_vec);
1219
1220    while feat_structs.len() < feats.len() {
1221        feat_structs.push(GffStruct::new(1));
1222    }
1223    if feat_structs.len() > feats.len() {
1224        feat_structs.truncate(feats.len());
1225    }
1226
1227    for (idx, feat_id) in feats.iter().copied().enumerate() {
1228        let feat_struct = &mut feat_structs[idx];
1229        upsert_field(feat_struct, "Feat", GffValue::UInt16(feat_id));
1230    }
1231
1232    feat_structs
1233}
1234
1235fn default_skill_structs() -> Vec<GffStruct> {
1236    let mut list = Vec::with_capacity(8);
1237    for _ in 0..8 {
1238        let mut skill = GffStruct::new(0);
1239        upsert_field(&mut skill, "Rank", GffValue::UInt8(0));
1240        list.push(skill);
1241    }
1242    list
1243}
1244
1245fn read_skill_rank(list: &[GffStruct], index: usize) -> u8 {
1246    list.get(index)
1247        .and_then(|skill| get_u8(skill, "Rank"))
1248        .unwrap_or(0)
1249}
1250
1251fn write_skill_rank(skill: &mut GffStruct, rank: u8) {
1252    upsert_field(skill, "Rank", GffValue::UInt8(rank));
1253}
1254
1255/// UTC `ClassList` entry child schema.
1256static CLASS_LIST_CHILDREN: &[FieldSchema] = &[
1257    FieldSchema {
1258        label: "KnownList0",
1259        expected_type: GffType::List,
1260        life: FieldLife::Live,
1261        required: false,
1262        absent: AbsentDefault::Unverified,
1263        children: None,
1264        constraint: None,
1265    },
1266    FieldSchema {
1267        label: "Class",
1268        expected_type: GffType::Int32,
1269        life: FieldLife::Live,
1270        required: false,
1271        absent: AbsentDefault::Unverified,
1272        children: None,
1273        constraint: None,
1274    },
1275    FieldSchema {
1276        label: "ClassLevel",
1277        expected_type: GffType::Int16,
1278        life: FieldLife::Live,
1279        required: false,
1280        absent: AbsentDefault::Unverified,
1281        children: None,
1282        constraint: None,
1283    },
1284    FieldSchema {
1285        label: "SpellsPerDayList",
1286        expected_type: GffType::List,
1287        life: FieldLife::Live,
1288        required: false,
1289        absent: AbsentDefault::Unverified,
1290        children: None,
1291        constraint: None,
1292    },
1293];
1294
1295/// UTC `FeatList` entry child schema.
1296static FEAT_LIST_CHILDREN: &[FieldSchema] = &[FieldSchema {
1297    label: "Feat",
1298    expected_type: GffType::UInt16,
1299    life: FieldLife::Live,
1300    required: false,
1301    absent: AbsentDefault::Unverified,
1302    children: None,
1303    constraint: None,
1304}];
1305
1306/// UTC `SkillList` entry child schema.
1307static SKILL_LIST_CHILDREN: &[FieldSchema] = &[FieldSchema {
1308    label: "Rank",
1309    expected_type: GffType::UInt8,
1310    life: FieldLife::Live,
1311    required: false,
1312    absent: AbsentDefault::Unverified,
1313    children: None,
1314    constraint: None,
1315}];
1316
1317/// UTC `SpecAbilityList` entry child schema.
1318static SPEC_ABILITY_LIST_CHILDREN: &[FieldSchema] = &[
1319    FieldSchema {
1320        label: "Spell",
1321        expected_type: GffType::UInt16,
1322        life: FieldLife::Live,
1323        required: false,
1324        absent: AbsentDefault::Unverified,
1325        children: None,
1326        constraint: None,
1327    },
1328    FieldSchema {
1329        label: "SpellFlags",
1330        expected_type: GffType::UInt8,
1331        life: FieldLife::Live,
1332        required: false,
1333        absent: AbsentDefault::Unverified,
1334        children: None,
1335        constraint: None,
1336    },
1337    FieldSchema {
1338        label: "SpellCasterLevel",
1339        expected_type: GffType::UInt8,
1340        life: FieldLife::Live,
1341        required: false,
1342        absent: AbsentDefault::Unverified,
1343        children: None,
1344        constraint: None,
1345    },
1346];
1347
1348/// UTC `ItemList` entry child schema.
1349static ITEM_LIST_CHILDREN: &[FieldSchema] = &[
1350    FieldSchema {
1351        label: "InventoryRes",
1352        expected_type: GffType::ResRef,
1353        life: FieldLife::Live,
1354        required: false,
1355        absent: AbsentDefault::Unverified,
1356        children: None,
1357        constraint: None,
1358    },
1359    FieldSchema {
1360        label: "Infinite",
1361        expected_type: GffType::UInt8,
1362        life: FieldLife::Live,
1363        required: false,
1364        absent: AbsentDefault::Unverified,
1365        children: None,
1366        constraint: None,
1367    },
1368    FieldSchema {
1369        label: "ObjectId",
1370        expected_type: GffType::UInt32,
1371        life: FieldLife::Live,
1372        required: false,
1373        absent: AbsentDefault::Unverified,
1374        children: None,
1375        constraint: None,
1376    },
1377    FieldSchema {
1378        label: "Dropable",
1379        expected_type: GffType::UInt8,
1380        life: FieldLife::Live,
1381        required: false,
1382        absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utc.md: Dropable is unconditional on both item lists, a literal 0 stamped regardless of presence"),
1383        children: None,
1384        constraint: None,
1385    },
1386    FieldSchema {
1387        label: "Repos_PosX",
1388        expected_type: GffType::UInt16,
1389        life: FieldLife::Live,
1390        required: false,
1391        absent: AbsentDefault::Unverified,
1392        children: None,
1393        constraint: None,
1394    },
1395    FieldSchema {
1396        label: "Repos_PosY",
1397        expected_type: GffType::UInt16,
1398        life: FieldLife::Live,
1399        required: false,
1400        absent: AbsentDefault::Unverified,
1401        children: None,
1402        constraint: None,
1403    },
1404    FieldSchema {
1405        label: "Repos_Posy",
1406        expected_type: GffType::UInt16,
1407        life: FieldLife::Live,
1408        required: false,
1409        absent: AbsentDefault::Unverified,
1410        children: None,
1411        constraint: None,
1412    },
1413];
1414
1415/// UTC `Equip_ItemList` entry child schema.
1416static EQUIP_ITEM_LIST_CHILDREN: &[FieldSchema] = &[
1417    FieldSchema {
1418        label: "EquippedRes",
1419        expected_type: GffType::ResRef,
1420        life: FieldLife::Live,
1421        required: false,
1422        absent: AbsentDefault::Unverified,
1423        children: None,
1424        constraint: None,
1425    },
1426    FieldSchema {
1427        label: "ObjectId",
1428        expected_type: GffType::UInt32,
1429        life: FieldLife::Live,
1430        required: false,
1431        absent: AbsentDefault::Unverified,
1432        children: None,
1433        constraint: None,
1434    },
1435    FieldSchema {
1436        label: "Dropable",
1437        expected_type: GffType::UInt8,
1438        life: FieldLife::Live,
1439        required: false,
1440        absent: AbsentDefault::Unverified,
1441        children: None,
1442        constraint: None,
1443    },
1444];
1445
1446impl GffSchema for Utc {
1447    fn schema() -> &'static [FieldSchema] {
1448        static SCHEMA: &[FieldSchema] = &[
1449            FieldSchema {
1450                label: "MaxHitPoints",
1451                expected_type: GffType::Int16,
1452                life: FieldLife::Live,
1453                required: false,
1454                absent: AbsentDefault::Unverified,
1455                children: None,
1456                constraint: None,
1457            },
1458            // ===== ReadStatsFromGff scalars (57) =====
1459            // --- Names / identity ---
1460            FieldSchema {
1461                label: "FirstName",
1462                expected_type: GffType::LocalizedString,
1463                life: FieldLife::Live,
1464                required: false,
1465                absent: AbsentDefault::Stamped(DefaultValue::EmptyLocalizedString, "utc.md: FirstName and LastName are read against a freshly default-constructed empty localized string, so an absent one overwrites rather than carrying over"),
1466                children: None,
1467                constraint: None,
1468            },
1469            FieldSchema {
1470                label: "LastName",
1471                expected_type: GffType::LocalizedString,
1472                life: FieldLife::Live,
1473                required: false,
1474                absent: AbsentDefault::Stamped(DefaultValue::EmptyLocalizedString, "utc.md: FirstName and LastName are read against a freshly default-constructed empty localized string, so an absent one overwrites rather than carrying over"),
1475                children: None,
1476                constraint: None,
1477            },
1478            FieldSchema {
1479                label: "Description",
1480                expected_type: GffType::LocalizedString,
1481                life: FieldLife::Live,
1482                required: false,
1483                absent: AbsentDefault::Constructed(DefaultValue::EmptyLocalizedString, "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1484                children: None,
1485                constraint: None,
1486            },
1487            FieldSchema {
1488                label: "IsPC",
1489                expected_type: GffType::UInt8,
1490                life: FieldLife::Live,
1491                required: false,
1492                absent: AbsentDefault::Unverified,
1493                children: None,
1494                constraint: None,
1495            },
1496            FieldSchema {
1497                label: "Tag",
1498                expected_type: GffType::String,
1499                life: FieldLife::Live,
1500                required: false,
1501                absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1502                children: None,
1503                constraint: None,
1504            },
1505            FieldSchema {
1506                label: "Conversation",
1507                expected_type: GffType::ResRef,
1508                life: FieldLife::Live,
1509                required: false,
1510                absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1511                children: None,
1512                constraint: None,
1513            },
1514            FieldSchema {
1515                label: "Interruptable",
1516                expected_type: GffType::UInt8,
1517                life: FieldLife::Live,
1518                required: false,
1519                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: BodyBag and Interruptable are read live via an ordinary carry-over byte read, unlike the identically-named dead field on UTD and UTP"),
1520                children: None,
1521                constraint: None,
1522            },
1523            // --- Demographics ---
1524            FieldSchema {
1525                label: "Age",
1526                expected_type: GffType::Int32,
1527                life: FieldLife::Live,
1528                required: false,
1529                absent: AbsentDefault::Constructed(DefaultValue::Int32(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1530                children: None,
1531                constraint: None,
1532            },
1533            FieldSchema {
1534                label: "Gender",
1535                expected_type: GffType::UInt8,
1536                life: FieldLife::Live,
1537                required: false,
1538                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1539                children: None,
1540                constraint: Some(FieldConstraint::RangeInt(0, 4)),
1541            },
1542            FieldSchema {
1543                label: "StartingPackage",
1544                expected_type: GffType::UInt8,
1545                life: FieldLife::Live,
1546                required: false,
1547                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1548                children: None,
1549                constraint: None,
1550            },
1551            FieldSchema {
1552                label: "Race",
1553                expected_type: GffType::UInt8,
1554                life: FieldLife::Live,
1555                required: false,
1556                absent: AbsentDefault::Unverified,
1557                children: None,
1558                constraint: None,
1559            },
1560            FieldSchema {
1561                label: "Subrace",
1562                expected_type: GffType::String,
1563                life: FieldLife::Live,
1564                required: false,
1565                absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1566                children: None,
1567                constraint: None,
1568            },
1569            FieldSchema {
1570                label: "SubraceIndex",
1571                expected_type: GffType::UInt8,
1572                life: FieldLife::Live,
1573                required: false,
1574                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1575                children: None,
1576                constraint: None,
1577            },
1578            FieldSchema {
1579                label: "Deity",
1580                expected_type: GffType::String,
1581                life: FieldLife::Live,
1582                required: false,
1583                absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1584                children: None,
1585                constraint: None,
1586            },
1587            // --- Ability scores ---
1588            FieldSchema {
1589                label: "Str",
1590                expected_type: GffType::UInt8,
1591                life: FieldLife::Live,
1592                required: false,
1593                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the constructor initializes all six ability scores to a literal 0 before any read, so an absent score resolves to 0 rather than a tabletop 10"),
1594                children: None,
1595                constraint: None,
1596            },
1597            FieldSchema {
1598                label: "Dex",
1599                expected_type: GffType::UInt8,
1600                life: FieldLife::Live,
1601                required: false,
1602                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the constructor initializes all six ability scores to a literal 0 before any read, so an absent score resolves to 0 rather than a tabletop 10"),
1603                children: None,
1604                constraint: None,
1605            },
1606            FieldSchema {
1607                label: "Int",
1608                expected_type: GffType::UInt8,
1609                life: FieldLife::Live,
1610                required: false,
1611                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the constructor initializes all six ability scores to a literal 0 before any read, so an absent score resolves to 0 rather than a tabletop 10"),
1612                children: None,
1613                constraint: None,
1614            },
1615            FieldSchema {
1616                label: "Wis",
1617                expected_type: GffType::UInt8,
1618                life: FieldLife::Live,
1619                required: false,
1620                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the constructor initializes all six ability scores to a literal 0 before any read, so an absent score resolves to 0 rather than a tabletop 10"),
1621                children: None,
1622                constraint: None,
1623            },
1624            FieldSchema {
1625                label: "Con",
1626                expected_type: GffType::UInt8,
1627                life: FieldLife::Live,
1628                required: false,
1629                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the constructor initializes all six ability scores to a literal 0 before any read, so an absent score resolves to 0 rather than a tabletop 10"),
1630                children: None,
1631                constraint: None,
1632            },
1633            FieldSchema {
1634                label: "Cha",
1635                expected_type: GffType::UInt8,
1636                life: FieldLife::Live,
1637                required: false,
1638                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the constructor initializes all six ability scores to a literal 0 before any read, so an absent score resolves to 0 rather than a tabletop 10"),
1639                children: None,
1640                constraint: None,
1641            },
1642            FieldSchema {
1643                label: "NaturalAC",
1644                expected_type: GffType::UInt8,
1645                life: FieldLife::Live,
1646                required: false,
1647                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1648                children: None,
1649                constraint: None,
1650            },
1651            // --- Audio / gold ---
1652            FieldSchema {
1653                label: "SoundSetFile",
1654                expected_type: GffType::UInt16,
1655                life: FieldLife::Live,
1656                required: false,
1657                absent: AbsentDefault::Unverified,
1658                children: None,
1659                constraint: None,
1660            },
1661            FieldSchema {
1662                label: "Gold",
1663                expected_type: GffType::UInt32,
1664                life: FieldLife::Live,
1665                required: false,
1666                absent: AbsentDefault::Unverified,
1667                children: None,
1668                constraint: None,
1669            },
1670            // --- Flags ---
1671            FieldSchema {
1672                label: "Invulnerable",
1673                expected_type: GffType::UInt8,
1674                life: FieldLife::Live,
1675                required: false,
1676                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the Invulnerable and Plot labels both land in one member, with a final fallback of the object's own prior plot value"),
1677                children: None,
1678                constraint: None,
1679            },
1680            FieldSchema {
1681                label: "Plot",
1682                expected_type: GffType::UInt8,
1683                life: FieldLife::Live,
1684                required: false,
1685                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the Invulnerable and Plot labels both land in one member, with a final fallback of the object's own prior plot value"),
1686                children: None,
1687                constraint: None,
1688            },
1689            FieldSchema {
1690                label: "Min1HP",
1691                expected_type: GffType::UInt8,
1692                life: FieldLife::Live,
1693                required: false,
1694                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1695                children: None,
1696                constraint: None,
1697            },
1698            FieldSchema {
1699                label: "PartyInteract",
1700                expected_type: GffType::UInt8,
1701                life: FieldLife::Live,
1702                required: false,
1703                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1704                children: None,
1705                constraint: None,
1706            },
1707            FieldSchema {
1708                label: "NotReorienting",
1709                expected_type: GffType::UInt8,
1710                life: FieldLife::Live,
1711                required: false,
1712                absent: AbsentDefault::Unverified,
1713                children: None,
1714                constraint: None,
1715            },
1716            FieldSchema {
1717                label: "Disarmable",
1718                expected_type: GffType::UInt8,
1719                life: FieldLife::Live,
1720                required: false,
1721                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1722                children: None,
1723                constraint: None,
1724            },
1725            // --- Combat / experience ---
1726            FieldSchema {
1727                label: "Experience",
1728                expected_type: GffType::UInt32,
1729                life: FieldLife::Live,
1730                required: false,
1731                absent: AbsentDefault::Constructed(DefaultValue::UInt32(0), "utc.md: Experience is absent from every vanilla file; the constructor holds 0 and SetExperience refuses to lower XP, so both sides agree"),
1732                children: None,
1733                constraint: None,
1734            },
1735            // --- Portrait ---
1736            FieldSchema {
1737                label: "PortraitId",
1738                expected_type: GffType::UInt16,
1739                life: FieldLife::Live,
1740                required: false,
1741                absent: AbsentDefault::Stamped(DefaultValue::UInt16(65535), "utc.md: PortraitId uses a fresh literal sentinel 0xFFFF, which fails the same < 0xFFFE check and routes to the Portrait resref path"),
1742                children: None,
1743                constraint: None,
1744            },
1745            FieldSchema {
1746                label: "Portrait",
1747                expected_type: GffType::ResRef,
1748                life: FieldLife::Live,
1749                required: false,
1750                absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1751                children: None,
1752                constraint: None,
1753            },
1754            // --- Alignment ---
1755            FieldSchema {
1756                label: "GoodEvil",
1757                expected_type: GffType::UInt8,
1758                life: FieldLife::Live,
1759                required: false,
1760                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1761                children: None,
1762                constraint: Some(FieldConstraint::RangeInt(0, 100)),
1763            },
1764            // --- Appearance ---
1765            FieldSchema {
1766                label: "Color_Skin",
1767                expected_type: GffType::UInt8,
1768                life: FieldLife::Live,
1769                required: false,
1770                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1771                children: None,
1772                constraint: None,
1773            },
1774            FieldSchema {
1775                label: "Color_Hair",
1776                expected_type: GffType::UInt8,
1777                life: FieldLife::Live,
1778                required: false,
1779                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1780                children: None,
1781                constraint: None,
1782            },
1783            FieldSchema {
1784                label: "Color_Tattoo1",
1785                expected_type: GffType::UInt8,
1786                life: FieldLife::Live,
1787                required: false,
1788                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1789                children: None,
1790                constraint: None,
1791            },
1792            FieldSchema {
1793                label: "Color_Tattoo2",
1794                expected_type: GffType::UInt8,
1795                life: FieldLife::Live,
1796                required: false,
1797                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1798                children: None,
1799                constraint: None,
1800            },
1801            FieldSchema {
1802                label: "Phenotype",
1803                expected_type: GffType::Int32,
1804                life: FieldLife::Live,
1805                required: false,
1806                absent: AbsentDefault::Constructed(DefaultValue::Int32(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1807                children: None,
1808                constraint: None,
1809            },
1810            FieldSchema {
1811                label: "Appearance_Type",
1812                expected_type: GffType::UInt16,
1813                life: FieldLife::Live,
1814                required: false,
1815                absent: AbsentDefault::Constructed(DefaultValue::UInt16(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1816                children: None,
1817                constraint: None,
1818            },
1819            FieldSchema {
1820                label: "Appearance_Head",
1821                expected_type: GffType::UInt8,
1822                life: FieldLife::Live,
1823                required: false,
1824                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the head constructor default is 0; the engine override of 0 to 1 checks the resolved value rather than presence, so it is resolution and not a read fallback"),
1825                children: None,
1826                constraint: None,
1827            },
1828            FieldSchema {
1829                label: "DuplicatingHead",
1830                expected_type: GffType::UInt8,
1831                life: FieldLife::Live,
1832                required: false,
1833                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1834                children: None,
1835                constraint: None,
1836            },
1837            FieldSchema {
1838                label: "UseBackupHead",
1839                expected_type: GffType::UInt8,
1840                life: FieldLife::Live,
1841                required: false,
1842                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1843                children: None,
1844                constraint: None,
1845            },
1846            // --- Faction ---
1847            FieldSchema {
1848                label: "FactionID",
1849                expected_type: GffType::UInt16,
1850                life: FieldLife::Live,
1851                required: false,
1852                absent: AbsentDefault::Constructed(DefaultValue::UInt16(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1853                children: None,
1854                constraint: None,
1855            },
1856            // --- CR / AI ---
1857            FieldSchema {
1858                label: "ChallengeRating",
1859                expected_type: GffType::Single,
1860                life: FieldLife::Live,
1861                required: false,
1862                absent: AbsentDefault::Constructed(DefaultValue::Single(0.0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1863                children: None,
1864                constraint: None,
1865            },
1866            FieldSchema {
1867                label: "AIState",
1868                expected_type: GffType::Int32,
1869                life: FieldLife::Live,
1870                required: false,
1871                absent: AbsentDefault::Constructed(DefaultValue::Int32(0), "utc.md: the read fallback is the object's own current value, stamped back unconditionally, so an absent field is a no-op on a freshly constructed load"),
1872                children: None,
1873                constraint: None,
1874            },
1875            FieldSchema {
1876                label: "BodyBag",
1877                expected_type: GffType::UInt8,
1878                life: FieldLife::Live,
1879                required: false,
1880                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utc.md: BodyBag and Interruptable are read live via an ordinary carry-over byte read, unlike the identically-named dead field on UTD and UTP"),
1881                children: None,
1882                constraint: None,
1883            },
1884            FieldSchema {
1885                label: "PerceptionRange",
1886                expected_type: GffType::UInt8,
1887                life: FieldLife::Live,
1888                required: false,
1889                absent: AbsentDefault::Unverified,
1890                children: None,
1891                constraint: None,
1892            },
1893            // --- Saves (engine reads bonus, NOT SaveWill/SaveFortitude) ---
1894            FieldSchema {
1895                label: "willbonus",
1896                expected_type: GffType::Int16,
1897                life: FieldLife::Live,
1898                required: false,
1899                absent: AbsentDefault::Unverified,
1900                children: None,
1901                constraint: None,
1902            },
1903            FieldSchema {
1904                label: "fortbonus",
1905                expected_type: GffType::Int16,
1906                life: FieldLife::Live,
1907                required: false,
1908                absent: AbsentDefault::Unverified,
1909                children: None,
1910                constraint: None,
1911            },
1912            FieldSchema {
1913                label: "refbonus",
1914                expected_type: GffType::Int16,
1915                life: FieldLife::Live,
1916                required: false,
1917                absent: AbsentDefault::Unverified,
1918                children: None,
1919                constraint: None,
1920            },
1921            // --- Hit points / force ---
1922            FieldSchema {
1923                label: "HitPoints",
1924                expected_type: GffType::Int16,
1925                life: FieldLife::Live,
1926                required: false,
1927                absent: AbsentDefault::Unverified,
1928                children: None,
1929                constraint: None,
1930            },
1931            FieldSchema {
1932                label: "ForcePoints",
1933                expected_type: GffType::Int16,
1934                life: FieldLife::Live,
1935                required: false,
1936                absent: AbsentDefault::Unverified,
1937                children: None,
1938                constraint: None,
1939            },
1940            FieldSchema {
1941                label: "CurrentHitPoints",
1942                expected_type: GffType::Int16,
1943                life: FieldLife::Live,
1944                required: false,
1945                absent: AbsentDefault::Unverified,
1946                children: None,
1947                constraint: None,
1948            },
1949            FieldSchema {
1950                label: "CurrentForce",
1951                expected_type: GffType::Int16,
1952                life: FieldLife::Live,
1953                required: false,
1954                absent: AbsentDefault::Unverified,
1955                children: None,
1956                constraint: None,
1957            },
1958            // --- Skill points / movement ---
1959            FieldSchema {
1960                label: "SkillPoints",
1961                expected_type: GffType::UInt16,
1962                life: FieldLife::Live,
1963                required: false,
1964                absent: AbsentDefault::Unverified,
1965                children: None,
1966                constraint: None,
1967            },
1968            FieldSchema {
1969                label: "MovementRate",
1970                expected_type: GffType::UInt8,
1971                life: FieldLife::Live,
1972                required: false,
1973                absent: AbsentDefault::NotAConstant("utc.md: MovementRate falls back to the WalkRate label; what the pair resolves to when neither is present is not documented"),
1974                children: None,
1975                constraint: None,
1976            },
1977            FieldSchema {
1978                label: "WalkRate",
1979                expected_type: GffType::Int32,
1980                life: FieldLife::Live,
1981                required: false,
1982                absent: AbsentDefault::NotAConstant("utc.md: WalkRate falls back to the object's current movement_rate, the mirror of the MovementRate-falls-back-to-WalkRate rule"),
1983                children: None,
1984                constraint: None,
1985            },
1986            // ===== LoadCreature scalars (13) =====
1987            FieldSchema {
1988                label: "CreatureSize",
1989                expected_type: GffType::Int32,
1990                life: FieldLife::Live,
1991                required: false,
1992                absent: AbsentDefault::Unverified,
1993                children: None,
1994                constraint: None,
1995            },
1996            FieldSchema {
1997                label: "IsDestroyable",
1998                expected_type: GffType::UInt8,
1999                life: FieldLife::Live,
2000                required: false,
2001                absent: AbsentDefault::Unverified,
2002                children: None,
2003                constraint: None,
2004            },
2005            FieldSchema {
2006                label: "IsRaiseable",
2007                expected_type: GffType::UInt8,
2008                life: FieldLife::Live,
2009                required: false,
2010                absent: AbsentDefault::Unverified,
2011                children: None,
2012                constraint: None,
2013            },
2014            FieldSchema {
2015                label: "DeadSelectable",
2016                expected_type: GffType::UInt8,
2017                life: FieldLife::Live,
2018                required: false,
2019                absent: AbsentDefault::Unverified,
2020                children: None,
2021                constraint: None,
2022            },
2023            FieldSchema {
2024                label: "AmbientAnimState",
2025                expected_type: GffType::UInt8,
2026                life: FieldLife::Live,
2027                required: false,
2028                absent: AbsentDefault::Unverified,
2029                children: None,
2030                constraint: None,
2031            },
2032            FieldSchema {
2033                label: "Animation",
2034                expected_type: GffType::Int32,
2035                life: FieldLife::Live,
2036                required: false,
2037                absent: AbsentDefault::Unverified,
2038                children: None,
2039                constraint: None,
2040            },
2041            FieldSchema {
2042                label: "CreatnScrptFird",
2043                expected_type: GffType::UInt8,
2044                life: FieldLife::Live,
2045                required: false,
2046                absent: AbsentDefault::Unverified,
2047                children: None,
2048                constraint: None,
2049            },
2050            FieldSchema {
2051                label: "PM_IsDisguised",
2052                expected_type: GffType::UInt8,
2053                life: FieldLife::Live,
2054                required: false,
2055                absent: AbsentDefault::Unverified,
2056                children: None,
2057                constraint: None,
2058            },
2059            FieldSchema {
2060                label: "PM_Appearance",
2061                expected_type: GffType::UInt16,
2062                life: FieldLife::Live,
2063                required: false,
2064                absent: AbsentDefault::Unverified,
2065                children: None,
2066                constraint: None,
2067            },
2068            FieldSchema {
2069                label: "Listening",
2070                expected_type: GffType::UInt8,
2071                life: FieldLife::Live,
2072                required: false,
2073                absent: AbsentDefault::Unverified,
2074                children: None,
2075                constraint: None,
2076            },
2077            FieldSchema {
2078                label: "AreaId",
2079                expected_type: GffType::UInt32,
2080                life: FieldLife::Live,
2081                required: false,
2082                absent: AbsentDefault::Unverified,
2083                children: None,
2084                constraint: None,
2085            },
2086            FieldSchema {
2087                label: "DetectMode",
2088                expected_type: GffType::UInt8,
2089                life: FieldLife::Live,
2090                required: false,
2091                absent: AbsentDefault::Unverified,
2092                children: None,
2093                constraint: None,
2094            },
2095            FieldSchema {
2096                label: "StealthMode",
2097                expected_type: GffType::UInt8,
2098                life: FieldLife::Live,
2099                required: false,
2100                absent: AbsentDefault::Unverified,
2101                children: None,
2102                constraint: None,
2103            },
2104            // ===== Scripts (14) =====
2105            FieldSchema {
2106                label: "ScriptHeartbeat",
2107                expected_type: GffType::ResRef,
2108                life: FieldLife::Live,
2109                required: false,
2110                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2111                children: None,
2112                constraint: None,
2113            },
2114            FieldSchema {
2115                label: "ScriptOnNotice",
2116                expected_type: GffType::ResRef,
2117                life: FieldLife::Live,
2118                required: false,
2119                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2120                children: None,
2121                constraint: None,
2122            },
2123            FieldSchema {
2124                label: "ScriptSpellAt",
2125                expected_type: GffType::ResRef,
2126                life: FieldLife::Live,
2127                required: false,
2128                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2129                children: None,
2130                constraint: None,
2131            },
2132            FieldSchema {
2133                label: "ScriptAttacked",
2134                expected_type: GffType::ResRef,
2135                life: FieldLife::Live,
2136                required: false,
2137                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2138                children: None,
2139                constraint: None,
2140            },
2141            FieldSchema {
2142                label: "ScriptDamaged",
2143                expected_type: GffType::ResRef,
2144                life: FieldLife::Live,
2145                required: false,
2146                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2147                children: None,
2148                constraint: None,
2149            },
2150            FieldSchema {
2151                label: "ScriptDisturbed",
2152                expected_type: GffType::ResRef,
2153                life: FieldLife::Live,
2154                required: false,
2155                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2156                children: None,
2157                constraint: None,
2158            },
2159            FieldSchema {
2160                label: "ScriptEndRound",
2161                expected_type: GffType::ResRef,
2162                life: FieldLife::Live,
2163                required: false,
2164                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2165                children: None,
2166                constraint: None,
2167            },
2168            FieldSchema {
2169                label: "ScriptDialogue",
2170                expected_type: GffType::ResRef,
2171                life: FieldLife::Live,
2172                required: false,
2173                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2174                children: None,
2175                constraint: None,
2176            },
2177            FieldSchema {
2178                label: "ScriptSpawn",
2179                expected_type: GffType::ResRef,
2180                life: FieldLife::Live,
2181                required: false,
2182                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2183                children: None,
2184                constraint: None,
2185            },
2186            FieldSchema {
2187                label: "ScriptRested",
2188                expected_type: GffType::ResRef,
2189                life: FieldLife::Live,
2190                required: false,
2191                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2192                children: None,
2193                constraint: None,
2194            },
2195            FieldSchema {
2196                label: "ScriptDeath",
2197                expected_type: GffType::ResRef,
2198                life: FieldLife::Live,
2199                required: false,
2200                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2201                children: None,
2202                constraint: None,
2203            },
2204            FieldSchema {
2205                label: "ScriptUserDefine",
2206                expected_type: GffType::ResRef,
2207                life: FieldLife::Live,
2208                required: false,
2209                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2210                children: None,
2211                constraint: None,
2212            },
2213            FieldSchema {
2214                label: "ScriptOnBlocked",
2215                expected_type: GffType::ResRef,
2216                life: FieldLife::Live,
2217                required: false,
2218                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2219                children: None,
2220                constraint: None,
2221            },
2222            FieldSchema {
2223                label: "ScriptEndDialogu",
2224                expected_type: GffType::ResRef,
2225                life: FieldLife::Live,
2226                required: false,
2227                absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utc.md: the creature constructor pre-arms all fourteen script slots with the literal `default` before any GFF read happens"),
2228                children: None,
2229                constraint: None,
2230            },
2231            // ===== Lists (7) =====
2232            FieldSchema {
2233                label: "ClassList",
2234                expected_type: GffType::List,
2235                life: FieldLife::Live,
2236                required: false,
2237                absent: AbsentDefault::Unverified,
2238                children: Some(CLASS_LIST_CHILDREN),
2239                constraint: None,
2240            },
2241            FieldSchema {
2242                label: "FeatList",
2243                expected_type: GffType::List,
2244                life: FieldLife::Live,
2245                required: false,
2246                absent: AbsentDefault::Unverified,
2247                children: Some(FEAT_LIST_CHILDREN),
2248                constraint: None,
2249            },
2250            FieldSchema {
2251                label: "SkillList",
2252                expected_type: GffType::List,
2253                life: FieldLife::Live,
2254                required: false,
2255                absent: AbsentDefault::NotAConstant(
2256                    "utc.md: an absent SkillList skips the block and leaves the eight skill ranks at whatever they held, eight zeroes on a fresh load, which is a structured value rather than one constant",
2257                ),
2258                children: Some(SKILL_LIST_CHILDREN),
2259                constraint: None,
2260            },
2261            FieldSchema {
2262                label: "Equip_ItemList",
2263                expected_type: GffType::List,
2264                life: FieldLife::Live,
2265                required: false,
2266                absent: AbsentDefault::Unverified,
2267                children: Some(EQUIP_ITEM_LIST_CHILDREN),
2268                constraint: None,
2269            },
2270            FieldSchema {
2271                label: "ItemList",
2272                expected_type: GffType::List,
2273                life: FieldLife::Live,
2274                required: false,
2275                absent: AbsentDefault::Unverified,
2276                children: Some(ITEM_LIST_CHILDREN),
2277                constraint: None,
2278            },
2279            FieldSchema {
2280                label: "SpecAbilityList",
2281                expected_type: GffType::List,
2282                life: FieldLife::Live,
2283                required: false,
2284                absent: AbsentDefault::Unverified,
2285                children: Some(SPEC_ABILITY_LIST_CHILDREN),
2286                constraint: None,
2287            },
2288            FieldSchema {
2289                label: "LvlStatList",
2290                expected_type: GffType::List,
2291                life: FieldLife::Live,
2292                required: false,
2293                absent: AbsentDefault::Unverified,
2294                children: None,
2295                constraint: None,
2296            },
2297            // ===== Toolset-only / K2 / computed (17) =====
2298            FieldSchema {
2299                label: "TemplateResRef",
2300                expected_type: GffType::ResRef,
2301                life: FieldLife::Live,
2302                required: false,
2303                absent: AbsentDefault::Unverified,
2304                children: None,
2305                constraint: None,
2306            },
2307            FieldSchema {
2308                label: "Comment",
2309                expected_type: GffType::String,
2310                life: FieldLife::Live,
2311                required: false,
2312                absent: AbsentDefault::Unverified,
2313                children: None,
2314                constraint: None,
2315            },
2316            FieldSchema {
2317                label: "PaletteID",
2318                expected_type: GffType::UInt8,
2319                life: FieldLife::Live,
2320                required: false,
2321                absent: AbsentDefault::Unverified,
2322                children: None,
2323                constraint: None,
2324            },
2325            FieldSchema {
2326                label: "SaveWill",
2327                expected_type: GffType::UInt8,
2328                life: FieldLife::Live,
2329                required: false,
2330                absent: AbsentDefault::Unverified,
2331                children: None,
2332                constraint: None,
2333            },
2334            FieldSchema {
2335                label: "SaveFortitude",
2336                expected_type: GffType::UInt8,
2337                life: FieldLife::Live,
2338                required: false,
2339                absent: AbsentDefault::Unverified,
2340                children: None,
2341                constraint: None,
2342            },
2343            FieldSchema {
2344                label: "BodyVariation",
2345                expected_type: GffType::UInt8,
2346                life: FieldLife::Live,
2347                required: false,
2348                absent: AbsentDefault::Unverified,
2349                children: None,
2350                constraint: None,
2351            },
2352            FieldSchema {
2353                label: "TextureVar",
2354                expected_type: GffType::UInt8,
2355                life: FieldLife::Live,
2356                required: false,
2357                absent: AbsentDefault::Unverified,
2358                children: None,
2359                constraint: None,
2360            },
2361            FieldSchema {
2362                label: "Morale",
2363                expected_type: GffType::UInt8,
2364                life: FieldLife::Live,
2365                required: false,
2366                absent: AbsentDefault::Unverified,
2367                children: None,
2368                constraint: None,
2369            },
2370            FieldSchema {
2371                label: "MoraleRecovery",
2372                expected_type: GffType::UInt8,
2373                life: FieldLife::Live,
2374                required: false,
2375                absent: AbsentDefault::Unverified,
2376                children: None,
2377                constraint: None,
2378            },
2379            FieldSchema {
2380                label: "MoraleBreakpoint",
2381                expected_type: GffType::UInt8,
2382                life: FieldLife::Live,
2383                required: false,
2384                absent: AbsentDefault::Unverified,
2385                children: None,
2386                constraint: None,
2387            },
2388            FieldSchema {
2389                label: "BlindSpot",
2390                expected_type: GffType::Single,
2391                life: FieldLife::Live,
2392                required: false,
2393                absent: AbsentDefault::Unverified,
2394                children: None,
2395                constraint: None,
2396            },
2397            FieldSchema {
2398                label: "MultiplierSet",
2399                expected_type: GffType::UInt8,
2400                life: FieldLife::Live,
2401                required: false,
2402                absent: AbsentDefault::Unverified,
2403                children: None,
2404                constraint: None,
2405            },
2406            FieldSchema {
2407                label: "NoPermDeath",
2408                expected_type: GffType::UInt8,
2409                life: FieldLife::Live,
2410                required: false,
2411                absent: AbsentDefault::Unverified,
2412                children: None,
2413                constraint: None,
2414            },
2415            FieldSchema {
2416                label: "IgnoreCrePath",
2417                expected_type: GffType::UInt8,
2418                life: FieldLife::Live,
2419                required: false,
2420                absent: AbsentDefault::Unverified,
2421                children: None,
2422                constraint: None,
2423            },
2424            FieldSchema {
2425                label: "Hologram",
2426                expected_type: GffType::UInt8,
2427                life: FieldLife::Live,
2428                required: false,
2429                absent: AbsentDefault::Unverified,
2430                children: None,
2431                constraint: None,
2432            },
2433            FieldSchema {
2434                label: "WillNotRender",
2435                expected_type: GffType::UInt8,
2436                life: FieldLife::Live,
2437                required: false,
2438                absent: AbsentDefault::Unverified,
2439                children: None,
2440                constraint: None,
2441            },
2442            FieldSchema {
2443                label: "LawfulChaotic",
2444                expected_type: GffType::UInt8,
2445                life: FieldLife::Live,
2446                required: false,
2447                absent: AbsentDefault::Unverified,
2448                children: None,
2449                constraint: None,
2450            },
2451        ];
2452        SCHEMA
2453    }
2454}
2455
2456#[cfg(test)]
2457mod tests {
2458    use super::*;
2459
2460    const TEST_UTC: &[u8] = include_bytes!(concat!(
2461        env!("CARGO_MANIFEST_DIR"),
2462        "/../../fixtures/test.utc"
2463    ));
2464
2465    #[test]
2466    fn reads_core_utc_fields_from_fixture() {
2467        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
2468
2469        assert_eq!(utc.template_resref, "n_minecoorta");
2470        assert_eq!(utc.tag, "Coorta");
2471        assert_eq!(utc.comment, "comment");
2472        assert_eq!(utc.conversation, "coorta");
2473
2474        assert_eq!(utc.first_name.string_ref.raw(), 76_046);
2475        assert_eq!(utc.last_name.string_ref.raw(), 123);
2476
2477        assert_eq!(utc.age, 25);
2478        assert_eq!(utc.starting_package, 3);
2479        assert_eq!(utc.gold, 500);
2480        assert!(utc.invulnerable);
2481        assert_eq!(utc.experience, 1200);
2482        assert_eq!(utc.color_skin, 2);
2483        assert_eq!(utc.color_hair, 4);
2484        assert_eq!(utc.color_tattoo1, 1);
2485        assert_eq!(utc.color_tattoo2, 3);
2486        assert_eq!(utc.appearance_head, 5);
2487        assert_eq!(utc.duplicating_head, 0);
2488        assert_eq!(utc.use_backup_head, 0);
2489        assert_eq!(utc.ai_state, 100);
2490        assert_eq!(utc.skill_points, 8);
2491        assert_eq!(utc.movement_rate, 7);
2492
2493        assert_eq!(utc.appearance_id, 636);
2494        assert_eq!(utc.gender_id, 2);
2495        assert_eq!(utc.race_id, 6);
2496        assert_eq!(utc.faction_id, 5);
2497        assert_eq!(utc.perception_id, 11);
2498        assert_eq!(utc.walkrate_id, 7);
2499        assert_eq!(utc.soundset_id, 46);
2500        assert_eq!(utc.portrait_id, 1);
2501        assert!(utc.portrait_resref.is_empty());
2502        assert_eq!(utc.save_will, 0);
2503        assert_eq!(utc.save_fortitude, 0);
2504        assert_eq!(utc.morale, 0);
2505        assert_eq!(utc.morale_recovery, 0);
2506        assert_eq!(utc.morale_breakpoint, 0);
2507        assert_eq!(utc.description.string_ref.raw(), 123);
2508        assert_eq!(utc.lawfulness, 0);
2509        assert_eq!(utc.phenotype_id, 0);
2510        assert!(utc.deity.is_empty());
2511        assert!(utc.subrace_name.is_empty());
2512
2513        assert_eq!(utc.alignment, 50);
2514        assert!((utc.challenge_rating - 1.0).abs() < f32::EPSILON);
2515        assert!((utc.blindspot - 120.0).abs() < f32::EPSILON);
2516        assert_eq!(utc.natural_ac, 1);
2517        assert_eq!(utc.reflex_bonus, 1);
2518        assert_eq!(utc.willpower_bonus, 1);
2519        assert_eq!(utc.fortitude_bonus, 1);
2520
2521        assert_eq!(utc.strength, 10);
2522        assert_eq!(utc.dexterity, 10);
2523        assert_eq!(utc.constitution, 10);
2524        assert_eq!(utc.intelligence, 10);
2525        assert_eq!(utc.wisdom, 10);
2526        assert_eq!(utc.charisma, 10);
2527
2528        assert_eq!(utc.current_hp, 8);
2529        assert_eq!(utc.max_hp, 8);
2530        assert_eq!(utc.hp, 8);
2531        assert_eq!(utc.fp, 1);
2532        assert_eq!(utc.max_fp, 1);
2533
2534        assert!(utc.not_reorienting);
2535        assert!(utc.party_interact);
2536        assert!(utc.no_perm_death);
2537        assert!(utc.min1_hp);
2538        assert!(utc.plot);
2539        assert!(utc.interruptable);
2540        assert!(utc.is_pc);
2541        assert!(utc.disarmable);
2542        assert!(utc.ignore_cre_path);
2543        assert!(utc.hologram);
2544
2545        assert_eq!(utc.on_attacked, "k_def_attacked01");
2546        assert_eq!(utc.on_damaged, "k_def_damage01");
2547        assert_eq!(utc.on_death, "k_def_death01");
2548        assert_eq!(utc.on_dialog, "k_def_dialogue01");
2549        assert_eq!(utc.on_disturbed, "k_def_disturb01");
2550        assert_eq!(utc.on_end_dialog, "k_def_endconv");
2551        assert_eq!(utc.on_end_round, "k_def_combend01");
2552        assert_eq!(utc.on_heartbeat, "k_def_heartbt01");
2553        assert_eq!(utc.on_blocked, "k_def_blocked01");
2554        assert_eq!(utc.on_notice, "k_def_percept01");
2555        assert_eq!(utc.on_spawn, "k_def_spawn01");
2556        assert_eq!(utc.on_spell, "k_def_spellat01");
2557        assert_eq!(utc.on_user_defined, "k_def_userdef01");
2558
2559        assert_eq!(utc.skills.computer_use, 1);
2560        assert_eq!(utc.skills.demolitions, 2);
2561        assert_eq!(utc.skills.stealth, 3);
2562        assert_eq!(utc.skills.awareness, 4);
2563        assert_eq!(utc.skills.persuade, 5);
2564        assert_eq!(utc.skills.repair, 6);
2565        assert_eq!(utc.skills.security, 7);
2566        assert_eq!(utc.skills.treat_injury, 8);
2567
2568        assert_eq!(utc.classes.len(), 2);
2569        let scout_class = utc
2570            .classes
2571            .iter()
2572            .find(|class| class.class_id == 1 && class.class_level == 3)
2573            .expect("fixture should contain class_id=1 level=3");
2574        assert_eq!(scout_class.powers, vec![9, 11]);
2575        assert!(utc.special_abilities.is_empty());
2576
2577        assert_eq!(utc.feats, vec![93, 94]);
2578
2579        assert_eq!(utc.equipment.len(), 2);
2580        assert_eq!(utc.equipment[0].slot_id, 2);
2581        assert_eq!(utc.equipment[0].resref, "mineruniform");
2582        assert!(utc.equipment[0].droppable);
2583        assert_eq!(utc.equipment[1].slot_id, 131_072);
2584        assert_eq!(utc.equipment[1].resref, "g_i_crhide008");
2585        assert!(!utc.equipment[1].droppable);
2586
2587        assert_eq!(utc.inventory.len(), 4);
2588        assert_eq!(utc.inventory[0].entry_id, 0);
2589        assert_eq!(utc.inventory[0].resref, "g_w_thermldet01");
2590        assert!(utc.inventory[0].droppable);
2591        assert_eq!(utc.inventory[1].entry_id, 1);
2592        assert_eq!(utc.inventory[1].resref, "g_w_thermldet01");
2593        assert!(!utc.inventory[1].droppable);
2594        assert_eq!(utc.inventory[3].resref, "g_w_thermldet02");
2595        assert_eq!(utc.inventory[3].repos_pos_x, Some(3));
2596        assert_eq!(utc.inventory[3].repos_pos_y, Some(0));
2597    }
2598
2599    #[test]
2600    fn all_fields_survive_typed_roundtrip() {
2601        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
2602        let encoded = write_utc_to_vec(&utc).expect("encode must succeed");
2603        let reparsed = read_utc_from_bytes(&encoded).expect("decode must succeed");
2604        assert_eq!(utc, reparsed);
2605    }
2606
2607    #[test]
2608    fn typed_edits_roundtrip_through_gff_writer() {
2609        let mut utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
2610        utc.tag = "Coorta_Mod".into();
2611        utc.on_spawn = ResRef::new("k_new_spawn").expect("valid test resref");
2612        utc.skills.persuade = 12;
2613        utc.portrait_resref = ResRef::new("po_pfha01").expect("valid test resref");
2614        utc.save_will = 11;
2615        utc.save_fortitude = 9;
2616        utc.morale = 8;
2617        utc.morale_recovery = 7;
2618        utc.morale_breakpoint = 6;
2619        utc.description = GffLocalizedString::new(StrRef::from_raw(42_424));
2620        utc.lawfulness = 35;
2621        utc.phenotype_id = 2;
2622        utc.deity = "The Force".into();
2623        utc.subrace_name = "Miner".into();
2624        utc.feats = vec![94, 93, 120];
2625        utc.classes[0].powers = vec![9, 12, 15];
2626        utc.special_abilities = vec![UtcSpecialAbility {
2627            spell_id: 321,
2628            spell_flags: 0b11,
2629            spell_caster_level: 7,
2630        }];
2631        utc.equipment[0].resref = ResRef::new("g_a_class4001").expect("valid test resref");
2632        utc.equipment[0].droppable = false;
2633        utc.inventory[0].resref = ResRef::new("g_w_blstrrfl01").expect("valid test resref");
2634        utc.inventory.push(UtcInventoryItem {
2635            entry_id: 4,
2636            resref: ResRef::new("g_i_progspike01").expect("valid test resref"),
2637            droppable: true,
2638            repos_pos_x: Some(4),
2639            repos_pos_y: Some(0),
2640        });
2641
2642        let encoded = write_utc_to_vec(&utc).expect("encode");
2643        let reparsed = read_utc_from_bytes(&encoded).expect("decode");
2644
2645        assert_eq!(reparsed.tag, "Coorta_Mod");
2646        assert_eq!(reparsed.on_spawn, "k_new_spawn");
2647        assert_eq!(reparsed.skills.persuade, 12);
2648        assert_eq!(reparsed.portrait_resref, "po_pfha01");
2649        assert_eq!(reparsed.save_will, 11);
2650        assert_eq!(reparsed.save_fortitude, 9);
2651        assert_eq!(reparsed.morale, 8);
2652        assert_eq!(reparsed.morale_recovery, 7);
2653        assert_eq!(reparsed.morale_breakpoint, 6);
2654        assert_eq!(reparsed.description.string_ref.raw(), 42_424);
2655        assert_eq!(reparsed.lawfulness, 35);
2656        assert_eq!(reparsed.phenotype_id, 2);
2657        assert_eq!(reparsed.deity, "The Force");
2658        assert_eq!(reparsed.subrace_name, "Miner");
2659        assert_eq!(reparsed.feats, vec![94, 93, 120]);
2660        assert_eq!(reparsed.classes[0].powers, vec![9, 12, 15]);
2661        assert_eq!(reparsed.special_abilities.len(), 1);
2662        assert_eq!(reparsed.special_abilities[0].spell_id, 321);
2663        assert_eq!(reparsed.special_abilities[0].spell_flags, 0b11);
2664        assert_eq!(reparsed.special_abilities[0].spell_caster_level, 7);
2665        assert_eq!(reparsed.equipment[0].resref, "g_a_class4001");
2666        assert!(!reparsed.equipment[0].droppable);
2667        assert_eq!(reparsed.inventory[0].resref, "g_w_blstrrfl01");
2668        assert_eq!(reparsed.inventory.len(), 5);
2669        assert_eq!(reparsed.inventory[4].entry_id, 4);
2670        assert_eq!(reparsed.inventory[4].resref, "g_i_progspike01");
2671        assert!(reparsed.inventory[4].droppable);
2672    }
2673
2674    #[test]
2675    fn no_op_rebuild_preserves_list_order_and_struct_ids() {
2676        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
2677        let rebuilt = utc.to_gff();
2678
2679        assert_eq!(
2680            list_struct_ids(find_list(&rebuilt.root, "FeatList")),
2681            vec![1, 1]
2682        );
2683        assert_eq!(
2684            list_struct_ids(find_list(&rebuilt.root, "Equip_ItemList")),
2685            vec![2, 131_072]
2686        );
2687        assert_eq!(
2688            list_struct_ids(find_list(&rebuilt.root, "ItemList")),
2689            vec![0, 1, 2, 3]
2690        );
2691        assert_eq!(
2692            list_u16_field(find_list(&rebuilt.root, "FeatList"), "Feat"),
2693            vec![93, 94]
2694        );
2695    }
2696
2697    #[test]
2698    fn rejects_non_utc_file_type() {
2699        let gff = Gff::new(*b"UTI ", GffStruct::new(-1));
2700        let err = Utc::from_gff(&gff).expect_err("must fail");
2701        assert!(matches!(err, UtcError::UnsupportedFileType(file_type) if file_type == *b"UTI "));
2702    }
2703
2704    #[test]
2705    fn read_utc_from_reader_matches_bytes_path() {
2706        let mut cursor = Cursor::new(TEST_UTC);
2707        let via_reader = read_utc(&mut cursor).expect("reader parse");
2708        let via_bytes = read_utc_from_bytes(TEST_UTC).expect("bytes parse");
2709        assert_eq!(via_reader.template_resref, via_bytes.template_resref);
2710        assert_eq!(via_reader.classes.len(), via_bytes.classes.len());
2711    }
2712
2713    #[test]
2714    fn type_mismatch_on_class_list_is_error() {
2715        let mut root = GffStruct::new(-1);
2716        root.push_field("ClassList", GffValue::UInt32(7));
2717        let gff = Gff::new(*b"UTC ", root);
2718        let err = Utc::from_gff(&gff).expect_err("must fail");
2719        assert!(matches!(
2720            err,
2721            UtcError::TypeMismatch {
2722                field: "ClassList",
2723                expected: "List"
2724            }
2725        ));
2726    }
2727
2728    #[test]
2729    fn type_mismatch_on_spec_ability_list_is_error() {
2730        let mut root = GffStruct::new(-1);
2731        root.push_field("SpecAbilityList", GffValue::UInt32(7));
2732        let gff = Gff::new(*b"UTC ", root);
2733        let err = Utc::from_gff(&gff).expect_err("must fail");
2734        assert!(matches!(
2735            err,
2736            UtcError::TypeMismatch {
2737                field: "SpecAbilityList",
2738                expected: "List"
2739            }
2740        ));
2741    }
2742
2743    #[test]
2744    fn write_utc_matches_direct_gff_writer() {
2745        let utc = read_utc_from_bytes(TEST_UTC).expect("fixture parse");
2746        let from_utc = write_utc_to_vec(&utc).expect("utc encode");
2747
2748        let gff = utc.to_gff();
2749        let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
2750        assert_eq!(from_utc, from_gff);
2751    }
2752
2753    fn find_list<'a>(structure: &'a GffStruct, label: &str) -> &'a [GffStruct] {
2754        match structure.field(label) {
2755            Some(GffValue::List(values)) => values.as_slice(),
2756            Some(other) => panic!("field {label} is not a list: {other:?}"),
2757            None => panic!("missing list field {label}"),
2758        }
2759    }
2760
2761    fn list_struct_ids(list: &[GffStruct]) -> Vec<i32> {
2762        list.iter().map(|entry| entry.struct_id).collect::<Vec<_>>()
2763    }
2764
2765    fn list_u16_field(list: &[GffStruct], label: &str) -> Vec<u16> {
2766        list.iter()
2767            .map(|entry| match entry.field(label) {
2768                Some(GffValue::UInt16(value)) => *value,
2769                Some(other) => panic!("field {label} is not UInt16: {other:?}"),
2770                None => panic!("missing field {label}"),
2771            })
2772            .collect::<Vec<_>>()
2773    }
2774
2775    #[test]
2776    fn schema_field_count() {
2777        assert_eq!(Utc::schema().len(), 109);
2778    }
2779
2780    #[test]
2781    fn schema_no_duplicate_labels() {
2782        let schema = Utc::schema();
2783        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
2784        labels.sort();
2785        let before = labels.len();
2786        labels.dedup();
2787        assert_eq!(before, labels.len(), "duplicate labels in UTC schema");
2788    }
2789
2790    #[test]
2791    fn schema_has_expected_list_children() {
2792        let schema = Utc::schema();
2793        let expected: &[(&str, usize)] = &[
2794            ("ClassList", 4),
2795            ("FeatList", 1),
2796            ("SkillList", 1),
2797            ("SpecAbilityList", 3),
2798            ("ItemList", 7),
2799            ("Equip_ItemList", 3),
2800        ];
2801        for (label, child_count) in expected {
2802            let field = schema
2803                .iter()
2804                .find(|f| f.label == *label)
2805                .unwrap_or_else(|| panic!("missing list field {label}"));
2806            let children = field
2807                .children
2808                .unwrap_or_else(|| panic!("{label} should have children"));
2809            assert_eq!(
2810                children.len(),
2811                *child_count,
2812                "{label} children count mismatch"
2813            );
2814        }
2815    }
2816}
2817
2818#[cfg(test)]
2819mod write_coverage_tests {
2820    use super::*;
2821    use rakata_formats::gff_schema::GffType;
2822
2823    /// A value distinguishable from the type's default, so a field that is
2824    /// read but never written shows up as a change across a round-trip.
2825    fn distinctive(kind: GffType) -> Option<GffValue> {
2826        Some(match kind {
2827            GffType::UInt8 => GffValue::UInt8(7),
2828            GffType::Int8 => GffValue::Int8(7),
2829            GffType::UInt16 => GffValue::UInt16(7),
2830            GffType::Int16 => GffValue::Int16(7),
2831            GffType::UInt32 => GffValue::UInt32(7),
2832            GffType::Int32 => GffValue::Int32(7),
2833            GffType::Single => GffValue::Single(7.5),
2834            GffType::String => GffValue::String("seven".to_string()),
2835            GffType::ResRef => GffValue::ResRef(ResRef::new("seven").expect("valid test resref")),
2836            GffType::LocalizedString => {
2837                GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(7)))
2838            }
2839            // Lists and the wider numeric types need per-field shapes; the
2840            // scalar sweep is what catches a dropped write.
2841            _ => return None,
2842        })
2843    }
2844
2845    #[test]
2846    fn every_schema_label_is_a_valid_gff_label() {
2847        // A schema label longer than the GFF limit can never match a real
2848        // field, so any rule keyed on it is silently dead. `ScriptEndDialogue`
2849        // was exactly that: 17 characters against a 16-character cap, while
2850        // the reader and writer used the engine's `ScriptEndDialogu`.
2851        let invalid: Vec<&str> = Utc::schema()
2852            .iter()
2853            .filter(|field| rakata_formats::gff::GffLabel::new(field.label).is_err())
2854            .map(|field| field.label)
2855            .collect();
2856
2857        assert!(
2858            invalid.is_empty(),
2859            "schema labels no GFF can hold: {invalid:?}"
2860        );
2861    }
2862
2863    #[test]
2864    fn a_field_that_is_read_is_also_written() {
2865        // Populate every scalar the schema names, then round-trip twice. A
2866        // field `from_gff` reads but `to_gff` omits comes back as its default
2867        // on the second parse, so the two typed values disagree.
2868        //
2869        // This does not need real data and does not need a list of which
2870        // fields the view models: a schema field the view ignores never
2871        // reaches the typed value, so it cannot differ.
2872        let mut root = GffStruct::new(-1);
2873        for field in Utc::schema() {
2874            if let Some(value) = distinctive(field.expected_type) {
2875                upsert_field(&mut root, field.label, value);
2876            }
2877        }
2878
2879        let first = Utc::from_gff(&Gff::new(*b"UTC ", root)).expect("populated UTC parses");
2880        let second = Utc::from_gff(&first.to_gff()).expect("rewritten UTC parses");
2881
2882        assert_eq!(
2883            first, second,
2884            "a field survived the read but was lost on write"
2885        );
2886    }
2887}