1use std::io::{Cursor, Read, Write};
37
38use rakata_core::ResRef;
39use rakata_formats::gff::{get_i32, get_u16, get_u8, upsert_field, GffLabel};
40use rakata_formats::gff_label;
41use rakata_formats::schema::FromGff;
42use rakata_formats::GENERIC_FILE_TYPE;
43use rakata_formats::{
44 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
45 GffStruct, GffValue,
46};
47use thiserror::Error;
48
49#[derive(Debug, Clone, PartialEq, GffModel)]
51#[gff_entry(CreatureSize, wire = i32, stamped = 3)]
52#[gff_entry(IsDestroyable, wire = u8, stamped = 1)]
53#[gff_entry(IsRaiseable, wire = u8, stamped = 1)]
54#[gff_entry(DeadSelectable, wire = u8, stamped = 1)]
55#[gff_entry(AmbientAnimState, wire = u8, stamped)]
56#[gff_entry(Animation, wire = i32, stamped = 10000)]
57#[gff_entry(CreatnScrptFird, wire = u8, stamped)]
58#[gff_entry(PM_IsDisguised, wire = u8, stamped)]
59#[gff_entry(PM_Appearance, wire = u16, stamped)]
60#[gff_entry(Listening, wire = u8, stamped)]
61#[gff_entry(AreaId, wire = u32, stamped)]
62#[gff_entry(DetectMode, wire = u8, stamped)]
63#[gff_entry(StealthMode, wire = u8, stamped)]
64#[gff_entry(LvlStatList, container = list, not_a_constant)]
65pub struct Utc {
66 #[gff(
68 MaxHitPoints,
69 write_only_dead = "the field-name string has exactly two cross-references in the binary, both writers, and zero readers anywhere on any path",
70 not_a_constant
71 )]
72 pub max_hp: i16,
73 #[gff(FirstName, stamped)]
75 pub first_name: GffLocalizedString,
76 #[gff(LastName, stamped)]
78 pub last_name: GffLocalizedString,
79 #[gff(Description, constructed)]
81 pub description: GffLocalizedString,
82 #[gff(IsPC, unexamined)]
84 pub is_pc: bool,
85 #[gff(Tag, constructed)]
87 pub tag: String,
88 #[gff(Conversation, constructed)]
90 pub conversation: ResRef,
91 #[gff(Interruptable, constructed)]
93 pub interruptable: bool,
94 #[gff(Age, constructed)]
96 pub age: i32,
97 #[gff(Gender, constructed, range_int = (0, 4))]
99 pub gender_id: u8,
100 #[gff(StartingPackage, constructed)]
102 pub starting_package: u8,
103 #[gff(Race, unexamined)]
105 pub race_id: u8,
106 #[gff(Subrace, constructed)]
108 pub subrace_name: String,
109 #[gff(SubraceIndex, constructed)]
111 pub subrace_id: u8,
112 #[gff(Deity, constructed)]
114 pub deity: String,
115 #[gff(Str, constructed)]
117 pub strength: u8,
118 #[gff(Dex, constructed)]
120 pub dexterity: u8,
121 #[gff(Int, constructed)]
123 pub intelligence: u8,
124 #[gff(Wis, constructed)]
126 pub wisdom: u8,
127 #[gff(Con, constructed)]
129 pub constitution: u8,
130 #[gff(Cha, constructed)]
132 pub charisma: u8,
133 #[gff(NaturalAC, constructed)]
135 pub natural_ac: u8,
136 #[gff(SoundSetFile, stamped = 65535)]
138 pub soundset_id: u16,
139 #[gff(Gold, constructed)]
141 pub gold: u32,
142 #[gff(Invulnerable, constructed, omit = audited_constant(2361))]
144 pub invulnerable: bool,
145 #[gff(Plot, constructed)]
147 pub plot: bool,
148 #[gff(Min1HP, constructed)]
150 pub min1_hp: bool,
151 #[gff(PartyInteract, constructed)]
153 pub party_interact: bool,
154 #[gff(NotReorienting, constructed)]
156 pub not_reorienting: bool,
157 #[gff(Disarmable, constructed)]
159 pub disarmable: bool,
160 #[gff(Experience, constructed)]
162 pub experience: u32,
163 #[gff(PortraitId, stamped = 65535)]
165 pub portrait_id: u16,
166 #[gff(Portrait, constructed)]
168 pub portrait_resref: ResRef,
169 #[gff(GoodEvil, constructed, range_int = (0, 100))]
171 pub alignment: u8,
172 #[gff(Color_Skin, constructed)]
174 pub color_skin: u8,
175 #[gff(Color_Hair, constructed)]
177 pub color_hair: u8,
178 #[gff(Color_Tattoo1, constructed)]
180 pub color_tattoo1: u8,
181 #[gff(Color_Tattoo2, constructed)]
183 pub color_tattoo2: u8,
184 #[gff(Phenotype, constructed)]
186 pub phenotype_id: i32,
187 #[gff(Appearance_Type, constructed)]
189 pub appearance_id: u16,
190 #[gff(Appearance_Head, constructed)]
192 pub appearance_head: u8,
193 #[gff(DuplicatingHead, constructed)]
195 pub duplicating_head: u8,
196 #[gff(UseBackupHead, constructed)]
198 pub use_backup_head: u8,
199 #[gff(FactionID, constructed)]
201 pub faction_id: u16,
202 #[gff(ChallengeRating, constructed)]
204 pub challenge_rating: f32,
205 #[gff(AIState, constructed, wire = i32)]
207 pub ai_state: u16,
208 #[gff(BodyBag, constructed)]
210 pub bodybag_id: u8,
211 #[gff(PerceptionRange, stamped = 11)]
213 pub perception_id: u8,
214 #[gff(willbonus, constructed)]
216 pub willpower_bonus: i16,
217 #[gff(fortbonus, constructed)]
219 pub fortitude_bonus: i16,
220 #[gff(refbonus, constructed)]
222 pub reflex_bonus: i16,
223 #[gff(HitPoints, live, constructed = 1)]
225 pub hp: i16,
226 #[gff(ForcePoints, constructed)]
228 pub max_fp: i16,
229 #[gff(CurrentHitPoints, live, not_a_constant)]
231 pub current_hp: i16,
232 #[gff(CurrentForce, not_a_constant)]
234 pub fp: i16,
235 #[gff(SkillPoints, constructed)]
237 pub skill_points: u16,
238 #[gff(MovementRate, manual_read, from_siblings, not_a_constant)]
240 pub movement_rate: u8,
241 #[gff(WalkRate, not_a_constant)]
243 pub walkrate_id: i32,
244 #[gff(ScriptHeartbeat, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
246 pub on_heartbeat: ResRef,
247 #[gff(ScriptOnNotice, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
249 pub on_notice: ResRef,
250 #[gff(ScriptSpellAt, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
252 pub on_spell: ResRef,
253 #[gff(ScriptAttacked, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
255 pub on_attacked: ResRef,
256 #[gff(ScriptDamaged, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
258 pub on_damaged: ResRef,
259 #[gff(ScriptDisturbed, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
261 pub on_disturbed: ResRef,
262 #[gff(ScriptEndRound, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
264 pub on_end_round: ResRef,
265 #[gff(ScriptDialogue, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
267 pub on_dialog: ResRef,
268 #[gff(ScriptSpawn, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
270 pub on_spawn: ResRef,
271 #[gff(ScriptRested, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
273 pub on_rested: ResRef,
274 #[gff(ScriptDeath, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
276 pub on_death: ResRef,
277 #[gff(ScriptUserDefine, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
279 pub on_user_defined: ResRef,
280 #[gff(ScriptOnBlocked, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
282 pub on_blocked: ResRef,
283 #[gff(ScriptEndDialogu, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
285 pub on_end_dialog: ResRef,
286 #[gff(ClassList, unexamined, list = UtcClass, element_id = 2)]
293 pub classes: Vec<UtcClass>,
294 #[gff(FeatList, manual_read, manual_write, unexamined, list = UtcFeatEntry, element_id = 1)]
296 pub feats: Vec<u16>,
297 #[gff(SkillList, manual_read, manual_write, not_a_constant, list = UtcSkillRank, element_id = 0)]
299 pub skills: UtcSkills,
300 #[gff(Equip_ItemList, manual_read, manual_write, unexamined, list = UtcEquipmentItem, element_id = meaningful)]
302 pub equipment: Vec<UtcEquipmentItem>,
303 #[gff(ItemList, not_a_constant, list = UtcInventoryItem, element_id = positional)]
305 pub inventory: Vec<UtcInventoryItem>,
306 #[gff(SpecAbilityList, not_a_constant, list = UtcSpecialAbility, element_id = 4)]
308 pub special_abilities: Vec<UtcSpecialAbility>,
309 #[gff(TemplateResRef, not_a_constant)]
311 pub template_resref: ResRef,
312 #[gff(
314 Comment,
315 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
316 not_a_constant
317 )]
318 pub comment: String,
319 #[gff(
321 PaletteID,
322 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
323 not_a_constant
324 )]
325 pub palette_id: u8,
326 #[gff(
328 SaveWill,
329 read_only_dead = "the template's own save-throw fields are ignored; the engine computes saving throws from base plus ability modifier plus active effects and reads these never",
330 not_a_constant
331 )]
332 pub save_will: u8,
333 #[gff(
335 SaveFortitude,
336 read_only_dead = "the template's own save-throw fields are ignored; the engine computes saving throws from base plus ability modifier plus active effects and reads these never",
337 not_a_constant
338 )]
339 pub save_fortitude: u8,
340 #[gff(
342 BodyVariation,
343 read_only_dead = "the string exists in the binary but its only cross-references are the item loader and saver, so it is a real field name on a different format and no creature function reads it",
344 not_a_constant
345 )]
346 pub body_variation: u8,
347 #[gff(
349 TextureVar,
350 read_only_dead = "the string exists in the binary but its only cross-references are the item loader and saver, so it is a real field name on a different format and no creature function reads it",
351 not_a_constant
352 )]
353 pub texture_variation: u8,
354 #[gff(
356 Morale,
357 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
358 not_a_constant
359 )]
360 pub morale: u8,
361 #[gff(
363 MoraleRecovery,
364 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
365 not_a_constant
366 )]
367 pub morale_recovery: u8,
368 #[gff(
370 MoraleBreakpoint,
371 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
372 not_a_constant
373 )]
374 pub morale_breakpoint: u8,
375 #[gff(
377 BlindSpot,
378 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
379 not_a_constant
380 )]
381 pub blindspot: f32,
382 #[gff(
384 MultiplierSet,
385 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
386 not_a_constant
387 )]
388 pub multiplier_set: u8,
389 #[gff(
391 NoPermDeath,
392 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
393 not_a_constant
394 )]
395 pub no_perm_death: bool,
396 #[gff(
398 IgnoreCrePath,
399 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
400 not_a_constant
401 )]
402 pub ignore_cre_path: bool,
403 #[gff(
405 Hologram,
406 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
407 not_a_constant
408 )]
409 pub hologram: bool,
410 #[gff(
412 WillNotRender,
413 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
414 not_a_constant
415 )]
416 pub will_not_render: bool,
417 #[gff(
419 LawfulChaotic,
420 read_only_dead = "the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored",
421 not_a_constant
422 )]
423 pub lawfulness: u8,
424}
425
426const SPELL_ABSENT: u16 = 0xFFFF;
433
434impl UtcClass {
435 fn read_element(structure: &GffStruct) -> Self {
441 let mut class = Self::read_declared(structure);
442 class.powers = parse_known_list(structure, "KnownList0");
443 class
444 }
445
446 fn write_element(&self, structure: &mut GffStruct) {
448 self.write_declared(structure);
449 write_known_list(structure, gff_label!("KnownList0"), &self.powers);
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
459struct UtcSkillRank {
460 #[gff(Rank, required, unexamined)]
461 rank: u8,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
474struct UtcKnownPower {
475 #[gff(Spell, unexamined)]
476 spell: u16,
477 #[gff(SpellFlags, unexamined)]
478 spell_flags: u8,
479 #[gff(SpellMetaMagic, unexamined)]
480 spell_meta_magic: u8,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
488struct UtcFeatEntry {
489 #[gff(Feat, required, unexamined)]
490 feat: u16,
491}
492
493#[derive(Debug, Clone, PartialEq, Default)]
495pub struct UtcSkills {
496 pub computer_use: u8,
498 pub demolitions: u8,
500 pub stealth: u8,
502 pub awareness: u8,
504 pub persuade: u8,
506 pub repair: u8,
508 pub security: u8,
510 pub treat_injury: u8,
512}
513
514impl UtcSkills {
515 fn from_list(list: &[GffStruct]) -> Self {
516 Self {
517 computer_use: read_skill_rank(list, 0),
518 demolitions: read_skill_rank(list, 1),
519 stealth: read_skill_rank(list, 2),
520 awareness: read_skill_rank(list, 3),
521 persuade: read_skill_rank(list, 4),
522 repair: read_skill_rank(list, 5),
523 security: read_skill_rank(list, 6),
524 treat_injury: read_skill_rank(list, 7),
525 }
526 }
527
528 fn to_list(&self) -> Vec<GffStruct> {
529 let mut list = default_skill_structs();
530
531 write_skill_rank(&mut list[0], self.computer_use);
532 write_skill_rank(&mut list[1], self.demolitions);
533 write_skill_rank(&mut list[2], self.stealth);
534 write_skill_rank(&mut list[3], self.awareness);
535 write_skill_rank(&mut list[4], self.persuade);
536 write_skill_rank(&mut list[5], self.repair);
537 write_skill_rank(&mut list[6], self.security);
538 write_skill_rank(&mut list[7], self.treat_injury);
539
540 list
541 }
542}
543
544impl Utc {
545 pub fn new() -> Self {
547 Self::default()
548 }
549
550 pub fn from_gff(gff: &Gff) -> Result<Self, UtcError> {
557 if gff.file_type != <Utc as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
558 return Err(UtcError::UnsupportedFileType(gff.file_type));
559 }
560
561 let root = &gff.root;
562 let mut utc = Self::read_declared(root);
563
564 if let Some(GffValue::List(ranks)) = root.field("SkillList") {
570 utc.skills = UtcSkills::from_list(ranks);
571 }
572 if let Some(GffValue::List(items)) = root.field("Equip_ItemList") {
575 utc.equipment = items
576 .iter()
577 .map(|element| {
578 let mut item = UtcEquipmentItem::read_declared(element);
579 item.slot_id = element.struct_id;
580 item
581 })
582 .collect();
583 }
584 utc.movement_rate = get_u8(root, "MovementRate")
588 .or_else(|| get_i32(root, "WalkRate").and_then(|v| u8::try_from(v).ok()))
589 .unwrap_or(0);
590 if let Some(GffValue::List(feats)) = root.field("FeatList") {
591 utc.feats = feats
592 .iter()
593 .map(|entry| get_u16(entry, "Feat").unwrap_or(0))
594 .collect();
595 }
596
597 Ok(utc)
598 }
599
600 pub fn to_gff(&self) -> Gff {
602 let mut root = GffStruct::new(-1);
603 self.write_declared(&mut root);
604
605 upsert_field(
606 &mut root,
607 gff_label!("SkillList"),
608 GffValue::List(self.skills.to_list()),
609 );
610 upsert_field(
611 &mut root,
612 gff_label!("FeatList"),
613 GffValue::List(
614 self.feats
615 .iter()
616 .map(|feat| {
617 let mut element = GffStruct::new(1);
618 element.push_field(gff_label!("Feat"), GffValue::UInt16(*feat));
619 element
620 })
621 .collect(),
622 ),
623 );
624 upsert_field(
627 &mut root,
628 gff_label!("Equip_ItemList"),
629 GffValue::List(
630 self.equipment
631 .iter()
632 .map(|item| {
633 let mut element = GffStruct::new(item.slot_id);
634 item.write_declared(&mut element);
635 element
636 })
637 .collect(),
638 ),
639 );
640
641 Gff::new(*b"UTC ", root)
642 }
643}
644
645#[derive(Debug, Clone, PartialEq, GffModel)]
647#[gff_entry(SpellsPerDayList, container = list, not_a_constant)]
648pub struct UtcClass {
649 #[gff(
651 KnownList0,
652 manual_read,
653 manual_write,
654 not_a_constant,
655 list = UtcKnownPower,
656 element_id = 3
657 )]
658 pub powers: Vec<u16>,
659 #[gff(Class, unexamined)]
661 pub class_id: i32,
662 #[gff(ClassLevel, unexamined)]
664 pub class_level: i16,
665}
666
667#[derive(Debug, Clone, PartialEq, GffModel)]
669pub struct UtcSpecialAbility {
670 #[gff(Spell, required, unexamined)]
672 pub spell_id: u16,
673 #[gff(SpellFlags, unexamined)]
675 pub spell_flags: u8,
676 #[gff(SpellCasterLevel, unexamined)]
678 pub spell_caster_level: u8,
679}
680
681#[derive(Debug, Clone, PartialEq, GffModel)]
683#[gff_entry(ObjectId, wire = u32, stamped = 2130706432)]
684pub struct UtcEquipmentItem {
685 pub slot_id: i32,
689 #[gff(EquippedRes, required, not_a_constant)]
691 pub resref: ResRef,
692 #[gff(Dropable, stamped)]
694 pub droppable: bool,
695}
696
697#[derive(Debug, Clone, PartialEq, GffModel)]
699#[gff_entry(Infinite, wire = u8, read_only_dead = "the Infinite label has exactly two cross-references in the binary, both inside CSWSStore::LoadStore and SaveStore, so no function in this type's item-loading call graph reads it", not_a_constant)]
700#[gff_entry(ObjectId, wire = u32, stamped = 2130706432)]
701#[gff_entry(Repos_PosY, wire = u16, read_only_dead = "never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches", not_a_constant)]
702pub struct UtcInventoryItem {
703 #[gff(InventoryRes, required, not_a_constant)]
705 pub resref: ResRef,
706 #[gff(Dropable, stamped)]
708 pub droppable: bool,
709 #[gff(Repos_PosX, read_only_dead = "never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches", not_a_constant, optional = u16)]
711 pub repos_pos_x: Option<u16>,
712 #[gff(Repos_Posy, read_only_dead = "never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches", not_a_constant, optional = u16)]
714 pub repos_pos_y: Option<u16>,
715}
716
717fn read_skill_rank(list: &[GffStruct], index: usize) -> u8 {
718 list.get(index)
719 .and_then(|skill| get_u8(skill, "Rank"))
720 .unwrap_or(0)
721}
722
723fn write_skill_rank(skill: &mut GffStruct, rank: u8) {
724 upsert_field(skill, gff_label!("Rank"), GffValue::UInt8(rank));
725}
726
727fn default_skill_structs() -> Vec<GffStruct> {
728 let mut list = Vec::with_capacity(8);
729 for _ in 0..8 {
730 let mut skill = GffStruct::new(0);
731 upsert_field(&mut skill, gff_label!("Rank"), GffValue::UInt8(0));
732 list.push(skill);
733 }
734 list
735}
736
737fn parse_known_list(structure: &GffStruct, label: &'static str) -> Vec<u16> {
738 match structure.field(label) {
739 Some(GffValue::List(power_structs)) => power_structs
745 .iter()
746 .filter_map(|power_struct| get_u16(power_struct, "Spell"))
747 .filter(|spell| *spell != SPELL_ABSENT)
748 .collect::<Vec<_>>(),
749 _ => Vec::new(),
750 }
751}
752
753fn write_known_list(structure: &mut GffStruct, label: GffLabel, powers: &[u16]) {
754 let power_structs = powers
755 .iter()
756 .copied()
757 .map(|spell| {
758 let mut s = GffStruct::new(3);
759 upsert_field(&mut s, gff_label!("Spell"), GffValue::UInt16(spell));
760 upsert_field(&mut s, gff_label!("SpellFlags"), GffValue::UInt8(1));
761 upsert_field(&mut s, gff_label!("SpellMetaMagic"), GffValue::UInt8(0));
762 s
763 })
764 .collect::<Vec<_>>();
765
766 upsert_field(structure, label, GffValue::List(power_structs));
767}
768
769#[derive(Debug, Error)]
771pub enum UtcError {
772 #[error("unsupported UTC file type: {0:?}")]
774 UnsupportedFileType([u8; 4]),
775 #[error(transparent)]
777 Gff(#[from] GffBinaryError),
778}
779
780#[cfg_attr(
788 feature = "tracing",
789 tracing::instrument(level = "debug", skip(reader))
790)]
791pub fn read_utc<R: Read>(reader: &mut R) -> Result<Utc, UtcError> {
792 let gff = read_gff(reader)?;
793 Utc::from_gff(&gff)
794}
795
796#[cfg_attr(
804 feature = "tracing",
805 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
806)]
807pub fn read_utc_from_bytes(bytes: &[u8]) -> Result<Utc, UtcError> {
808 let gff = read_gff_from_bytes(bytes)?;
809 Utc::from_gff(&gff)
810}
811
812#[cfg_attr(
820 feature = "tracing",
821 tracing::instrument(level = "debug", skip(writer, utc))
822)]
823pub fn author_utc<W: Write>(writer: &mut W, utc: &Utc) -> Result<(), UtcError> {
824 let gff = utc.to_gff();
825 write_gff(writer, &gff)?;
826 Ok(())
827}
828
829#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utc)))]
836pub fn author_utc_to_vec(utc: &Utc) -> Result<Vec<u8>, UtcError> {
837 let mut cursor = Cursor::new(Vec::new());
838 author_utc(&mut cursor, utc)?;
839 Ok(cursor.into_inner())
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use rakata_core::StrRef;
846 use rakata_formats::schema::{HasSchema, Shape};
847 use rakata_formats::{gff_label, GffValue};
848
849 const TEST_UTC: &[u8] = include_bytes!(concat!(
850 env!("CARGO_MANIFEST_DIR"),
851 "/../../fixtures/test.utc"
852 ));
853
854 #[test]
861 fn known_list_drops_the_entries_the_engine_never_appends() {
862 let entry = |spell: Option<u16>| {
863 let mut s = GffStruct::new(3);
864 if let Some(value) = spell {
865 upsert_field(&mut s, gff_label!("Spell"), GffValue::UInt16(value));
866 }
867 s
868 };
869 let mut list = GffStruct::new(0);
870 upsert_field(
871 &mut list,
872 gff_label!("KnownList0"),
873 GffValue::List(vec![
874 entry(Some(7)),
875 entry(Some(SPELL_ABSENT)),
876 entry(Some(9)),
877 entry(None),
878 ]),
879 );
880
881 let powers = parse_known_list(&list, "KnownList0");
882 assert_eq!(
883 powers,
884 vec![7, 9],
885 "the sentinel entry and the one with no Spell should both be gone"
886 );
887 }
888
889 #[test]
897 fn movement_rate_resolves_across_every_presence_combination() {
898 let build = |movement: Option<u8>, walk: Option<i32>| {
899 let mut gff = Gff::new(*b"UTC ", GffStruct::new(0));
900 if let Some(v) = movement {
901 upsert_field(
902 &mut gff.root,
903 gff_label!("MovementRate"),
904 GffValue::UInt8(v),
905 );
906 }
907 if let Some(v) = walk {
908 upsert_field(&mut gff.root, gff_label!("WalkRate"), GffValue::Int32(v));
909 }
910 Utc::from_gff(&gff).expect("a UTC with no required fields parses")
911 };
912
913 assert_eq!(build(Some(3), Some(9)).movement_rate, 3);
915 assert_eq!(build(None, Some(9)).movement_rate, 9);
917 assert_eq!(build(None, None).movement_rate, 0);
919 assert_eq!(build(Some(3), Some(9)).walkrate_id, 9);
923 }
924
925 #[test]
926 fn reads_core_utc_fields_from_fixture() {
927 let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
928
929 assert_eq!(utc.template_resref, "n_minecoorta");
930 assert_eq!(utc.tag, "Coorta");
931 assert_eq!(utc.comment, "comment");
932 assert_eq!(utc.conversation, "coorta");
933
934 assert_eq!(utc.first_name.string_ref.raw(), 76_046);
935 assert_eq!(utc.last_name.string_ref.raw(), 123);
936
937 assert_eq!(utc.age, 25);
938 assert_eq!(utc.starting_package, 3);
939 assert_eq!(utc.gold, 500);
940 assert!(utc.invulnerable);
941 assert_eq!(utc.experience, 1200);
942 assert_eq!(utc.color_skin, 2);
943 assert_eq!(utc.color_hair, 4);
944 assert_eq!(utc.color_tattoo1, 1);
945 assert_eq!(utc.color_tattoo2, 3);
946 assert_eq!(utc.appearance_head, 5);
947 assert_eq!(utc.duplicating_head, 0);
948 assert_eq!(utc.use_backup_head, 0);
949 assert_eq!(utc.ai_state, 100);
950 assert_eq!(utc.skill_points, 8);
951 assert_eq!(utc.movement_rate, 7);
952
953 assert_eq!(utc.appearance_id, 636);
954 assert_eq!(utc.gender_id, 2);
955 assert_eq!(utc.race_id, 6);
956 assert_eq!(utc.faction_id, 5);
957 assert_eq!(utc.perception_id, 11);
958 assert_eq!(utc.walkrate_id, 7);
959 assert_eq!(utc.soundset_id, 46);
960 assert_eq!(utc.portrait_id, 1);
961 assert!(utc.portrait_resref.is_empty());
962 assert_eq!(utc.save_will, 0);
963 assert_eq!(utc.save_fortitude, 0);
964 assert_eq!(utc.morale, 0);
965 assert_eq!(utc.morale_recovery, 0);
966 assert_eq!(utc.morale_breakpoint, 0);
967 assert_eq!(utc.description.string_ref.raw(), 123);
968 assert_eq!(utc.lawfulness, 0);
969 assert_eq!(utc.phenotype_id, 0);
970 assert!(utc.deity.is_empty());
971 assert!(utc.subrace_name.is_empty());
972
973 assert_eq!(utc.alignment, 50);
974 assert!((utc.challenge_rating - 1.0).abs() < f32::EPSILON);
975 assert!((utc.blindspot - 120.0).abs() < f32::EPSILON);
976 assert_eq!(utc.natural_ac, 1);
977 assert_eq!(utc.reflex_bonus, 1);
978 assert_eq!(utc.willpower_bonus, 1);
979 assert_eq!(utc.fortitude_bonus, 1);
980
981 assert_eq!(utc.strength, 10);
982 assert_eq!(utc.dexterity, 10);
983 assert_eq!(utc.constitution, 10);
984 assert_eq!(utc.intelligence, 10);
985 assert_eq!(utc.wisdom, 10);
986 assert_eq!(utc.charisma, 10);
987
988 assert_eq!(utc.current_hp, 8);
989 assert_eq!(utc.max_hp, 8);
990 assert_eq!(utc.hp, 8);
991 assert_eq!(utc.fp, 1);
992 assert_eq!(utc.max_fp, 1);
993
994 assert!(utc.not_reorienting);
995 assert!(utc.party_interact);
996 assert!(utc.no_perm_death);
997 assert!(utc.min1_hp);
998 assert!(utc.plot);
999 assert!(utc.interruptable);
1000 assert!(utc.is_pc);
1001 assert!(utc.disarmable);
1002 assert!(utc.ignore_cre_path);
1003 assert!(utc.hologram);
1004
1005 assert_eq!(utc.on_attacked, "k_def_attacked01");
1006 assert_eq!(utc.on_damaged, "k_def_damage01");
1007 assert_eq!(utc.on_death, "k_def_death01");
1008 assert_eq!(utc.on_dialog, "k_def_dialogue01");
1009 assert_eq!(utc.on_disturbed, "k_def_disturb01");
1010 assert_eq!(utc.on_end_dialog, "k_def_endconv");
1011 assert_eq!(utc.on_end_round, "k_def_combend01");
1012 assert_eq!(utc.on_heartbeat, "k_def_heartbt01");
1013 assert_eq!(utc.on_blocked, "k_def_blocked01");
1014 assert_eq!(utc.on_notice, "k_def_percept01");
1015 assert_eq!(utc.on_spawn, "k_def_spawn01");
1016 assert_eq!(utc.on_spell, "k_def_spellat01");
1017 assert_eq!(utc.on_user_defined, "k_def_userdef01");
1018
1019 assert_eq!(utc.skills.computer_use, 1);
1020 assert_eq!(utc.skills.demolitions, 2);
1021 assert_eq!(utc.skills.stealth, 3);
1022 assert_eq!(utc.skills.awareness, 4);
1023 assert_eq!(utc.skills.persuade, 5);
1024 assert_eq!(utc.skills.repair, 6);
1025 assert_eq!(utc.skills.security, 7);
1026 assert_eq!(utc.skills.treat_injury, 8);
1027
1028 assert_eq!(utc.classes.len(), 2);
1029 let scout_class = utc
1030 .classes
1031 .iter()
1032 .find(|class| class.class_id == 1 && class.class_level == 3)
1033 .expect("fixture should contain class_id=1 level=3");
1034 assert_eq!(scout_class.powers, vec![9, 11]);
1035 assert!(utc.special_abilities.is_empty());
1036
1037 assert_eq!(utc.feats, vec![93, 94]);
1038
1039 assert_eq!(utc.equipment.len(), 2);
1040 assert_eq!(utc.equipment[0].slot_id, 2);
1041 assert_eq!(utc.equipment[0].resref, "mineruniform");
1042 assert!(utc.equipment[0].droppable);
1043 assert_eq!(utc.equipment[1].slot_id, 131_072);
1044 assert_eq!(utc.equipment[1].resref, "g_i_crhide008");
1045 assert!(!utc.equipment[1].droppable);
1046
1047 assert_eq!(utc.inventory.len(), 4);
1048 assert_eq!(utc.inventory[0].resref, "g_w_thermldet01");
1051 assert!(utc.inventory[0].droppable);
1052 assert_eq!(utc.inventory[1].resref, "g_w_thermldet01");
1053 assert!(!utc.inventory[1].droppable);
1054 assert_eq!(utc.inventory[3].resref, "g_w_thermldet02");
1055 assert_eq!(utc.inventory[3].repos_pos_x, Some(3));
1056 assert_eq!(utc.inventory[3].repos_pos_y, Some(0));
1057 }
1058
1059 #[test]
1060 fn all_fields_survive_typed_roundtrip() {
1061 let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
1062 let encoded = author_utc_to_vec(&utc).expect("encode must succeed");
1063 let reparsed = read_utc_from_bytes(&encoded).expect("decode must succeed");
1064 assert_eq!(utc, reparsed);
1065 }
1066
1067 #[test]
1068 fn typed_edits_roundtrip_through_gff_writer() {
1069 let mut utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
1070 utc.tag = "Coorta_Mod".into();
1071 utc.on_spawn = ResRef::new("k_new_spawn").expect("valid test resref");
1072 utc.skills.persuade = 12;
1073 utc.portrait_resref = ResRef::new("po_pfha01").expect("valid test resref");
1074 utc.save_will = 11;
1075 utc.save_fortitude = 9;
1076 utc.morale = 8;
1077 utc.morale_recovery = 7;
1078 utc.morale_breakpoint = 6;
1079 utc.description = GffLocalizedString::new(StrRef::from_raw(42_424));
1080 utc.lawfulness = 35;
1081 utc.phenotype_id = 2;
1082 utc.deity = "The Force".into();
1083 utc.subrace_name = "Miner".into();
1084 utc.feats = vec![94, 93, 120];
1085 utc.classes[0].powers = vec![9, 12, 15];
1086 utc.special_abilities = vec![UtcSpecialAbility {
1087 spell_id: 321,
1088 spell_flags: 0b11,
1089 spell_caster_level: 7,
1090 }];
1091 utc.equipment[0].resref = ResRef::new("g_a_class4001").expect("valid test resref");
1092 utc.equipment[0].droppable = false;
1093 utc.inventory[0].resref = ResRef::new("g_w_blstrrfl01").expect("valid test resref");
1094 utc.inventory.push(UtcInventoryItem {
1095 resref: ResRef::new("g_i_progspike01").expect("valid test resref"),
1096 droppable: true,
1097 repos_pos_x: Some(4),
1098 repos_pos_y: Some(0),
1099 });
1100
1101 let encoded = author_utc_to_vec(&utc).expect("encode");
1102 let reparsed = read_utc_from_bytes(&encoded).expect("decode");
1103
1104 assert_eq!(reparsed.tag, "Coorta_Mod");
1105 assert_eq!(reparsed.on_spawn, "k_new_spawn");
1106 assert_eq!(reparsed.skills.persuade, 12);
1107 assert_eq!(reparsed.portrait_resref, "po_pfha01");
1108 assert_eq!(reparsed.save_will, 11);
1109 assert_eq!(reparsed.save_fortitude, 9);
1110 assert_eq!(reparsed.morale, 8);
1111 assert_eq!(reparsed.morale_recovery, 7);
1112 assert_eq!(reparsed.morale_breakpoint, 6);
1113 assert_eq!(reparsed.description.string_ref.raw(), 42_424);
1114 assert_eq!(reparsed.lawfulness, 35);
1115 assert_eq!(reparsed.phenotype_id, 2);
1116 assert_eq!(reparsed.deity, "The Force");
1117 assert_eq!(reparsed.subrace_name, "Miner");
1118 assert_eq!(reparsed.feats, vec![94, 93, 120]);
1119 assert_eq!(reparsed.classes[0].powers, vec![9, 12, 15]);
1120 assert_eq!(reparsed.special_abilities.len(), 1);
1121 assert_eq!(reparsed.special_abilities[0].spell_id, 321);
1122 assert_eq!(reparsed.special_abilities[0].spell_flags, 0b11);
1123 assert_eq!(reparsed.special_abilities[0].spell_caster_level, 7);
1124 assert_eq!(reparsed.equipment[0].resref, "g_a_class4001");
1125 assert!(!reparsed.equipment[0].droppable);
1126 assert_eq!(reparsed.inventory[0].resref, "g_w_blstrrfl01");
1127 assert_eq!(reparsed.inventory.len(), 5);
1128 assert_eq!(reparsed.inventory[4].resref, "g_i_progspike01");
1129 assert!(reparsed.inventory[4].droppable);
1130 }
1131
1132 #[test]
1133 fn no_op_rebuild_preserves_list_order_and_struct_ids() {
1134 let utc = read_utc_from_bytes(TEST_UTC).expect("fixture must parse");
1135 let rebuilt = utc.to_gff();
1136
1137 assert_eq!(
1138 list_struct_ids(find_list(&rebuilt.root, "FeatList")),
1139 vec![1, 1]
1140 );
1141 assert_eq!(
1142 list_struct_ids(find_list(&rebuilt.root, "Equip_ItemList")),
1143 vec![2, 131_072]
1144 );
1145 assert_eq!(
1146 list_struct_ids(find_list(&rebuilt.root, "ItemList")),
1147 vec![0, 1, 2, 3]
1148 );
1149 assert_eq!(
1150 list_u16_field(find_list(&rebuilt.root, "FeatList"), "Feat"),
1151 vec![93, 94]
1152 );
1153 }
1154
1155 #[test]
1156 fn rejects_non_utc_file_type() {
1157 let gff = Gff::new(*b"UTI ", GffStruct::new(-1));
1158 let err = Utc::from_gff(&gff).expect_err("must fail");
1159 assert!(matches!(err, UtcError::UnsupportedFileType(file_type) if file_type == *b"UTI "));
1160 }
1161
1162 #[test]
1163 fn read_utc_from_reader_matches_bytes_path() {
1164 let mut cursor = Cursor::new(TEST_UTC);
1165 let via_reader = read_utc(&mut cursor).expect("reader parse");
1166 let via_bytes = read_utc_from_bytes(TEST_UTC).expect("bytes parse");
1167 assert_eq!(via_reader.template_resref, via_bytes.template_resref);
1168 assert_eq!(via_reader.classes.len(), via_bytes.classes.len());
1169 }
1170
1171 #[test]
1172 fn a_mistyped_class_list_reads_as_empty() {
1173 let mut root = GffStruct::new(-1);
1174 root.push_field(gff_label!("ClassList"), GffValue::UInt32(7));
1175 let gff = Gff::new(*b"UTC ", root);
1176
1177 let utc = Utc::from_gff(&gff).expect("a mistyped list is not a read failure");
1178
1179 assert!(utc.classes.is_empty());
1180 }
1181
1182 #[test]
1183 fn a_mistyped_spec_ability_list_reads_as_empty() {
1184 let mut root = GffStruct::new(-1);
1185 root.push_field(gff_label!("SpecAbilityList"), GffValue::UInt32(7));
1186 let gff = Gff::new(*b"UTC ", root);
1187
1188 let utc = Utc::from_gff(&gff).expect("a mistyped list is not a read failure");
1189
1190 assert!(utc.special_abilities.is_empty());
1191 }
1192
1193 #[test]
1194 fn write_utc_matches_direct_gff_writer() {
1195 let utc = read_utc_from_bytes(TEST_UTC).expect("fixture parse");
1196 let from_utc = author_utc_to_vec(&utc).expect("utc encode");
1197
1198 let gff = utc.to_gff();
1199 let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
1200 assert_eq!(from_utc, from_gff);
1201 }
1202
1203 fn find_list<'a>(structure: &'a GffStruct, label: &str) -> &'a [GffStruct] {
1204 match structure.field(label) {
1205 Some(GffValue::List(values)) => values.as_slice(),
1206 Some(other) => panic!("field {label} is not a list: {other:?}"),
1207 None => panic!("missing list field {label}"),
1208 }
1209 }
1210
1211 fn list_struct_ids(list: &[GffStruct]) -> Vec<i32> {
1212 list.iter().map(|entry| entry.struct_id).collect::<Vec<_>>()
1213 }
1214
1215 fn list_u16_field(list: &[GffStruct], label: &str) -> Vec<u16> {
1216 list.iter()
1217 .map(|entry| match entry.field(label) {
1218 Some(GffValue::UInt16(value)) => *value,
1219 Some(other) => panic!("field {label} is not UInt16: {other:?}"),
1220 None => panic!("missing field {label}"),
1221 })
1222 .collect::<Vec<_>>()
1223 }
1224
1225 #[test]
1226 fn schema_field_count() {
1227 assert_eq!(Utc::schema().len(), 109);
1228 }
1229
1230 #[test]
1231 fn schema_no_duplicate_labels() {
1232 let mut labels: Vec<&str> = Utc::schema().iter().map(|f| f.label.as_str()).collect();
1233 labels.sort_unstable();
1234 let before = labels.len();
1235 labels.dedup();
1236 assert_eq!(before, labels.len(), "duplicate labels in UTC schema");
1237 }
1238
1239 #[test]
1240 fn schema_lists_carry_their_elements() {
1241 let element_count = |label: &str| {
1242 let field = Utc::schema()
1243 .iter()
1244 .find(|f| f.label.as_str() == label)
1245 .expect("declared");
1246 match field.shape {
1247 Shape::List { element, .. } => element.iter().map(|p| p.len()).sum::<usize>(),
1250 other => panic!("{label} is a list, got {other:?}"),
1251 }
1252 };
1253 assert_eq!(element_count("ClassList"), 4);
1254 assert_eq!(element_count("SpecAbilityList"), 3);
1255 assert_eq!(element_count("Equip_ItemList"), 3);
1256 assert_eq!(element_count("ItemList"), 7);
1257 assert_eq!(element_count("SkillList"), 1);
1260 assert_eq!(element_count("FeatList"), 1);
1261 assert_eq!(element_count("LvlStatList"), 0);
1262 }
1263}