1use std::io::{Cursor, Read, Write};
30
31use crate::shared::{PORTRAIT_ID_USE_RESREF, REPOS_UNPLACED_WORD, TRAP_TYPE_ABSENT};
32use rakata_core::ResRef;
33use rakata_formats::gff::get_bool;
34use rakata_formats::schema::FromGff;
35use rakata_formats::GENERIC_FILE_TYPE;
36use rakata_formats::{
37 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
38 GffStruct,
39};
40use thiserror::Error;
41
42#[derive(Debug, Clone, PartialEq, GffModel)]
47#[gff_entry(
48 LoadScreenID,
49 wire = u16,
50 read_only_dead = "utp.md never names the field, and the engine reads it on doors, triggers and areas rather than placeables",
51 not_a_constant
52)]
53#[gff_entry(
54 OnFailToOpen,
55 wire = ResRef,
56 read_only_dead = "this hook belongs to doors; a placeable never reads it",
57 unexamined
58)]
59pub struct Utp {
60 #[gff(TemplateResRef, unexamined)]
62 pub template_resref: ResRef,
63 #[gff(Tag, stamped)]
65 pub tag: String,
66 #[gff(LocName, stamped)]
68 pub name: GffLocalizedString,
69 #[gff(Description, stamped)]
71 pub description: GffLocalizedString,
72 #[gff(
74 Comment,
75 read_only_dead = "legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads",
76 not_a_constant
77 )]
78 pub comment: String,
79 #[gff(Conversation, stamped)]
81 pub conversation: ResRef,
82 #[gff(Faction, stamped)]
84 pub faction_id: u32,
85 #[gff(Appearance, unexamined, range_int = (0, 255))]
87 pub appearance_id: u32,
88 #[gff(AnimationState, stamped)]
90 pub animation_state: u8,
91 #[gff(Animation, stamped, omit = audited_constant(1784))]
93 pub animation: i32,
94 #[gff(Open, stamped, omit = audited_constant(1784))]
96 pub open: bool,
97 #[gff(Lockable, stamped)]
99 pub lockable: bool,
100 #[gff(Locked, stamped)]
102 pub locked: bool,
103 #[gff(KeyRequired, stamped)]
105 pub key_required: bool,
106 #[gff(KeyName, stamped)]
108 pub key_name: String,
109 #[gff(AutoRemoveKey, stamped)]
111 pub auto_remove_key: bool,
112 #[gff(OpenLockDC, stamped)]
114 pub open_lock_dc: u8,
115 #[gff(CloseLockDC, stamped)]
117 pub close_lock_dc: u8,
118 #[gff(
120 OpenLockDiff,
121 read_only_dead = "legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads",
122 not_a_constant
123 )]
124 pub open_lock_diff: u8,
125 #[gff(
127 OpenLockDiffMod,
128 read_only_dead = "legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads",
129 not_a_constant
130 )]
131 pub open_lock_diff_mod: i8,
132 #[gff(CurrentHP, stamped)]
134 pub current_hp: i16,
135 #[gff(HP, stamped)]
137 pub maximum_hp: i16,
138 #[gff(Hardness, stamped)]
140 pub hardness: u8,
141 #[gff(Fort, stamped)]
143 pub fortitude: u8,
144 #[gff(Ref, stamped)]
146 pub reflex: u8,
147 #[gff(Will, stamped)]
149 pub will: u8,
150 #[gff(Plot, constructed)]
152 pub plot: bool,
153 #[gff(Invulnerable, constructed, omit = audited_constant(1784))]
155 pub invulnerable: bool,
156 #[gff(Min1HP, constructed)]
158 pub min1_hp: bool,
159 #[gff(
161 NotBlastable,
162 read_only_dead = "read by nothing on the placeable path",
163 not_a_constant
164 )]
165 pub not_blastable: bool,
166 #[gff(Static, from_siblings, manual_read, not_a_constant)]
168 pub is_static: bool,
169 #[gff(Useable, stamped)]
171 pub useable: bool,
172 #[gff(PartyInteract, stamped)]
174 pub party_interact: bool,
175 #[gff(HasInventory, stamped)]
177 pub has_inventory: bool,
178 #[gff(DieWhenEmpty, stamped, omit = audited_constant(1784))]
180 pub die_when_empty: bool,
181 #[gff(
183 GroundPile,
184 read_only_dead = "the loader reads this and throws the result away, then forces the placeable to a ground pile regardless",
185 unexamined = true
186 )]
187 pub ground_pile: bool,
188 #[gff(LightState, not_a_constant)]
190 pub light_state: bool,
191 #[gff(Interruptable, unexamined)]
193 pub interruptable: bool,
194 #[gff(PortraitId, stamped = PORTRAIT_ID_USE_RESREF)]
196 pub portrait_id: u16,
197 #[gff(Portrait, stamped)]
199 pub portrait: ResRef,
200 #[gff(
202 PaletteID,
203 read_only_dead = "legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads",
204 not_a_constant
205 )]
206 pub palette_id: u8,
207 #[gff(BodyBag, constructed)]
209 pub bodybag_id: u8,
210 #[gff(Type, unexamined)]
212 pub type_id: u8,
213 #[gff(IsBodyBag, constructed, omit = audited_constant(1784))]
215 pub is_body_bag: bool,
216 #[gff(IsCorpse, constructed, omit = audited_constant(1784))]
218 pub is_corpse: bool,
219 #[gff(TrapDetectable, stamped)]
221 pub trap_detectable: bool,
222 #[gff(TrapDetectDC, stamped)]
224 pub trap_detect_dc: u8,
225 #[gff(TrapDisarmable, stamped)]
227 pub trap_disarmable: bool,
228 #[gff(DisarmDC, stamped)]
230 pub trap_disarm_dc: u8,
231 #[gff(TrapFlag, stamped)]
233 pub trap_flag: u8,
234 #[gff(TrapOneShot, stamped)]
236 pub trap_one_shot: bool,
237 #[gff(TrapType, constructed = TRAP_TYPE_ABSENT)]
239 pub trap_type: u8,
240 #[gff(OnClosed, stamped)]
242 pub on_closed: ResRef,
243 #[gff(OnDamaged, stamped)]
245 pub on_damaged: ResRef,
246 #[gff(OnDeath, stamped)]
248 pub on_death: ResRef,
249 #[gff(OnDisarm, stamped)]
251 pub on_disarm: ResRef,
252 #[gff(OnHeartbeat, stamped)]
254 pub on_heartbeat: ResRef,
255 #[gff(OnInvDisturbed, stamped)]
257 pub on_inventory: ResRef,
258 #[gff(OnLock, stamped)]
260 pub on_lock: ResRef,
261 #[gff(OnMeleeAttacked, stamped)]
263 pub on_melee_attacked: ResRef,
264 #[gff(OnOpen, stamped)]
266 pub on_open: ResRef,
267 #[gff(OnSpellCastAt, stamped)]
269 pub on_spell_cast_at: ResRef,
270 #[gff(OnUnlock, stamped)]
272 pub on_unlock: ResRef,
273 #[gff(OnUsed, stamped)]
275 pub on_used: ResRef,
276 #[gff(OnUserDefined, stamped)]
278 pub on_user_defined: ResRef,
279 #[gff(OnDialog, stamped, omit = audited_constant(1784))]
281 pub on_dialog: ResRef,
282 #[gff(OnEndDialogue, stamped)]
284 pub on_end_dialogue: ResRef,
285 #[gff(OnTrapTriggered, stamped)]
287 pub on_trap_triggered: ResRef,
288 #[gff(ItemList, not_a_constant, list = UtpInventoryItem, element_id = positional)]
290 pub inventory: Vec<UtpInventoryItem>,
291}
292
293impl Utp {
294 pub fn new() -> Self {
296 Self::default()
297 }
298
299 pub fn from_gff(gff: &Gff) -> Result<Self, UtpError> {
306 if gff.file_type != <Utp as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
307 return Err(UtpError::UnsupportedFileType(gff.file_type));
308 }
309
310 let root = &gff.root;
311 let mut utp = Self::read_declared(root);
312
313 utp.is_static = match root.field("Static") {
318 Some(_) => get_bool(root, "Static").unwrap_or(false),
319 None => !utp.useable,
320 };
321
322 Ok(utp)
323 }
324
325 pub fn to_gff(&self) -> Gff {
327 let mut root = GffStruct::new(-1);
328 self.write_declared(&mut root);
329 Gff::new(*b"UTP ", root)
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
339#[gff_entry(
340 Infinite,
341 wire = bool,
342 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",
343 not_a_constant
344)]
345#[gff_entry(ObjectId, wire = u32, stamped = 0x7F00_0000)]
346#[gff_entry(
347 Repos_PosY,
348 wire = u16,
349 read_only_dead = "no Repos_PosY string exists in swkotor.exe on any object type; the engine reads Repos_PosX and lowercase Repos_Posy only",
350 not_a_constant
351)]
352pub struct UtpInventoryItem {
353 #[gff(InventoryRes, unexamined)]
355 pub inventory_res: ResRef,
356 #[gff(Dropable, stamped, omit = audited_constant(1784))]
361 pub droppable: bool,
362 #[gff(
364 Repos_PosX,
365 read_only_dead = "read only by the loader for items inside a bag, which drops it and adds the item with no position; LoadPlaceable never reads it",
366 stamped = REPOS_UNPLACED_WORD
367 )]
368 pub repos_pos_x: u16,
369 #[gff(
371 Repos_Posy,
372 read_only_dead = "read only by the loader for items inside a bag, which drops it and adds the item with no position; LoadPlaceable never reads it",
373 stamped = REPOS_UNPLACED_WORD
374 )]
375 pub repos_pos_y: u16,
376}
377
378#[derive(Debug, Error)]
380pub enum UtpError {
381 #[error("unsupported UTP file type: {0:?}")]
383 UnsupportedFileType([u8; 4]),
384 #[error(transparent)]
386 Gff(#[from] GffBinaryError),
387}
388
389#[cfg_attr(
397 feature = "tracing",
398 tracing::instrument(level = "debug", skip(reader))
399)]
400pub fn read_utp<R: Read>(reader: &mut R) -> Result<Utp, UtpError> {
401 let gff = read_gff(reader)?;
402 Utp::from_gff(&gff)
403}
404
405#[cfg_attr(
413 feature = "tracing",
414 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
415)]
416pub fn read_utp_from_bytes(bytes: &[u8]) -> Result<Utp, UtpError> {
417 let gff = read_gff_from_bytes(bytes)?;
418 Utp::from_gff(&gff)
419}
420
421#[cfg_attr(
429 feature = "tracing",
430 tracing::instrument(level = "debug", skip(writer, utp))
431)]
432pub fn author_utp<W: Write>(writer: &mut W, utp: &Utp) -> Result<(), UtpError> {
433 let gff = utp.to_gff();
434 write_gff(writer, &gff)?;
435 Ok(())
436}
437
438#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utp)))]
445pub fn author_utp_to_vec(utp: &Utp) -> Result<Vec<u8>, UtpError> {
446 let mut cursor = Cursor::new(Vec::new());
447 author_utp(&mut cursor, utp)?;
448 Ok(cursor.into_inner())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use rakata_formats::schema::{HasSchema, Shape};
455 use rakata_formats::{gff_label, GffValue};
456
457 const TEST_UTP: &[u8] = include_bytes!(concat!(
458 env!("CARGO_MANIFEST_DIR"),
459 "/../../fixtures/test.utp"
460 ));
461
462 #[test]
463 fn reads_core_utp_fields_from_fixture() {
464 let utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
465
466 assert_eq!(utp.tag, "SecLoc");
467 assert_eq!(utp.template_resref, "lockerlg002");
468 assert_eq!(utp.name.string_ref.raw(), 74_450);
469 assert_eq!(utp.description.string_ref.raw(), -1);
470 assert!(utp.auto_remove_key);
471 assert_eq!(utp.close_lock_dc, 13);
472 assert_eq!(utp.conversation, "conversation");
473 assert_eq!(utp.faction_id, 1);
474 assert!(utp.plot);
475 assert!(utp.not_blastable);
476 assert!(utp.min1_hp);
477 assert!(utp.key_required);
478 assert!(!utp.lockable);
479 assert!(utp.locked);
480 assert_eq!(utp.open_lock_dc, 28);
481 assert_eq!(utp.open_lock_diff, 1);
482 assert_eq!(utp.open_lock_diff_mod, 1);
483 assert_eq!(utp.key_name, "somekey");
484 assert_eq!(utp.animation_state, 2);
485 assert_eq!(utp.appearance_id, 67);
486 assert_eq!(utp.maximum_hp, 15);
487 assert_eq!(utp.current_hp, 15);
488 assert_eq!(utp.hardness, 5);
489 assert_eq!(utp.fortitude, 16);
490 assert_eq!(utp.reflex, 0);
491 assert_eq!(utp.will, 0);
492 assert_eq!(utp.on_closed, "onclosed");
493 assert_eq!(utp.on_damaged, "ondamaged");
494 assert_eq!(utp.on_death, "ondeath");
495 assert_eq!(utp.on_disarm, "ondisarm");
496 assert_eq!(utp.on_heartbeat, "onheartbeat");
497 assert_eq!(utp.on_inventory, "oninvdisturbed");
498 assert_eq!(utp.on_lock, "onlock");
499 assert_eq!(utp.on_melee_attacked, "onmeleeattacked");
500 assert_eq!(utp.on_open, "onopen");
501 assert_eq!(utp.on_spell_cast_at, "onspellcastat");
502 assert_eq!(utp.on_unlock, "onunlock");
503 assert_eq!(utp.on_used, "onused");
504 assert_eq!(utp.on_user_defined, "onuserdefined");
505 assert_eq!(utp.on_end_dialogue, "onenddialogue");
506 assert!(utp.has_inventory);
507 assert!(utp.party_interact);
508 assert!(utp.is_static);
509 assert!(utp.useable);
510 assert_eq!(utp.comment, "Large standup locker");
511 assert!(utp.interruptable);
512 assert_eq!(utp.portrait_id, 0);
513 assert!(utp.trap_detectable);
514 assert_eq!(utp.trap_detect_dc, 0);
515 assert!(utp.trap_disarmable);
516 assert_eq!(utp.trap_disarm_dc, 15);
517 assert_eq!(utp.trap_flag, 0);
518 assert!(utp.trap_one_shot);
519 assert_eq!(utp.trap_type, 0);
520 assert_eq!(utp.bodybag_id, 0);
521 assert_eq!(utp.type_id, 0);
522 assert_eq!(utp.palette_id, 6);
523
524 assert_eq!(utp.inventory.len(), 2);
525 assert_eq!(utp.inventory[0].inventory_res, "g_w_iongren01");
526 assert!(!utp.inventory[0].droppable);
527 assert_eq!(utp.inventory[1].inventory_res, "g_w_iongren02");
528 assert!(utp.inventory[1].droppable);
529 }
530
531 #[test]
532 fn all_fields_survive_typed_roundtrip() {
533 let utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
534 let bytes = author_utp_to_vec(&utp).expect("write succeeds");
535 let reparsed = read_utp_from_bytes(&bytes).expect("reparse succeeds");
536 assert_eq!(reparsed, utp);
537 }
538
539 #[test]
540 fn typed_edits_roundtrip_through_gff_writer() {
541 let mut utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
542 utp.tag = "SecLocRust".into();
543 utp.open_lock_dc = 33;
544 utp.locked = false;
545 utp.inventory[0].droppable = true;
546 utp.comment = "Rust comment".into();
547 utp.on_open = ResRef::new("k_on_open_new").expect("valid test resref");
548
549 let encoded = author_utp_to_vec(&utp).expect("encode");
550 let reparsed = read_utp_from_bytes(&encoded).expect("decode");
551
552 assert_eq!(reparsed.tag, "SecLocRust");
553 assert_eq!(reparsed.open_lock_dc, 33);
554 assert!(!reparsed.locked);
555 assert!(reparsed.inventory[0].droppable);
556 assert_eq!(reparsed.comment, "Rust comment");
557 assert_eq!(reparsed.on_open, "k_on_open_new");
558 }
559
560 #[test]
561 fn static_defaults_to_inverse_of_useable_when_static_missing() {
562 let mut root = GffStruct::new(-1);
563 root.push_field(gff_label!("Useable"), GffValue::UInt8(1));
564 root.push_field(gff_label!("ItemList"), GffValue::List(Vec::new()));
565 let utp = Utp::from_gff(&Gff::new(*b"UTP ", root)).expect("must parse");
566 assert!(!utp.is_static);
567
568 let mut root2 = GffStruct::new(-1);
569 root2.push_field(gff_label!("Useable"), GffValue::UInt8(0));
570 root2.push_field(gff_label!("ItemList"), GffValue::List(Vec::new()));
571 let utp2 = Utp::from_gff(&Gff::new(*b"UTP ", root2)).expect("must parse");
572 assert!(utp2.is_static);
573 }
574
575 #[test]
576 fn rejects_non_utp_file_type() {
577 let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
578 let err = Utp::from_gff(&gff).expect_err("must fail");
579 assert!(matches!(err, UtpError::UnsupportedFileType(file_type) if file_type == *b"UTC "));
580 }
581
582 #[test]
583 fn read_utp_from_reader_matches_bytes_path() {
584 let mut cursor = Cursor::new(TEST_UTP);
585 let via_reader = read_utp(&mut cursor).expect("reader parse");
586 let via_bytes = read_utp_from_bytes(TEST_UTP).expect("bytes parse");
587 assert_eq!(via_reader.tag, via_bytes.tag);
588 assert_eq!(via_reader.inventory.len(), via_bytes.inventory.len());
589 }
590
591 #[test]
592 fn a_mistyped_item_list_reads_as_empty() {
593 let mut root = GffStruct::new(-1);
594 root.push_field(gff_label!("ItemList"), GffValue::UInt32(7));
595 let gff = Gff::new(*b"UTP ", root);
596
597 let utp = Utp::from_gff(&gff).expect("a mistyped list is not a read failure");
598
599 assert!(utp.inventory.is_empty());
600 }
601
602 #[test]
603 fn write_utp_matches_direct_gff_writer() {
604 let utp = read_utp_from_bytes(TEST_UTP).expect("fixture parse");
605 let from_utp = author_utp_to_vec(&utp).expect("utp encode");
606
607 let gff = utp.to_gff();
608 let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
609 assert_eq!(from_utp, from_gff);
610 }
611
612 #[test]
613 fn schema_field_count() {
614 assert_eq!(Utp::schema().len(), 71);
615 }
616
617 #[test]
618 fn schema_no_duplicate_labels() {
619 let mut labels: Vec<&str> = Utp::schema().iter().map(|f| f.label.as_str()).collect();
620 labels.sort_unstable();
621 let before = labels.len();
622 labels.dedup();
623 assert_eq!(before, labels.len(), "duplicate labels in UTP schema");
624 }
625
626 #[test]
627 fn schema_item_list_carries_its_element() {
628 let item_list = Utp::schema()
629 .iter()
630 .find(|f| f.label.as_str() == "ItemList")
631 .expect("ItemList is declared");
632 let Shape::List { element, .. } = item_list.shape else {
633 panic!("ItemList is a list");
634 };
635 assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 7);
637 }
638}