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(Repos_PosX, stamped = REPOS_UNPLACED_WORD)]
364 pub repos_pos_x: u16,
365 #[gff(Repos_Posy, stamped = REPOS_UNPLACED_WORD)]
367 pub repos_pos_y: u16,
368}
369
370#[derive(Debug, Error)]
372pub enum UtpError {
373 #[error("unsupported UTP file type: {0:?}")]
375 UnsupportedFileType([u8; 4]),
376 #[error(transparent)]
378 Gff(#[from] GffBinaryError),
379}
380
381#[cfg_attr(
389 feature = "tracing",
390 tracing::instrument(level = "debug", skip(reader))
391)]
392pub fn read_utp<R: Read>(reader: &mut R) -> Result<Utp, UtpError> {
393 let gff = read_gff(reader)?;
394 Utp::from_gff(&gff)
395}
396
397#[cfg_attr(
405 feature = "tracing",
406 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
407)]
408pub fn read_utp_from_bytes(bytes: &[u8]) -> Result<Utp, UtpError> {
409 let gff = read_gff_from_bytes(bytes)?;
410 Utp::from_gff(&gff)
411}
412
413#[cfg_attr(
421 feature = "tracing",
422 tracing::instrument(level = "debug", skip(writer, utp))
423)]
424pub fn author_utp<W: Write>(writer: &mut W, utp: &Utp) -> Result<(), UtpError> {
425 let gff = utp.to_gff();
426 write_gff(writer, &gff)?;
427 Ok(())
428}
429
430#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utp)))]
437pub fn author_utp_to_vec(utp: &Utp) -> Result<Vec<u8>, UtpError> {
438 let mut cursor = Cursor::new(Vec::new());
439 author_utp(&mut cursor, utp)?;
440 Ok(cursor.into_inner())
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use rakata_formats::schema::{HasSchema, Shape};
447 use rakata_formats::{gff_label, GffValue};
448
449 const TEST_UTP: &[u8] = include_bytes!(concat!(
450 env!("CARGO_MANIFEST_DIR"),
451 "/../../fixtures/test.utp"
452 ));
453
454 #[test]
455 fn reads_core_utp_fields_from_fixture() {
456 let utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
457
458 assert_eq!(utp.tag, "SecLoc");
459 assert_eq!(utp.template_resref, "lockerlg002");
460 assert_eq!(utp.name.string_ref.raw(), 74_450);
461 assert_eq!(utp.description.string_ref.raw(), -1);
462 assert!(utp.auto_remove_key);
463 assert_eq!(utp.close_lock_dc, 13);
464 assert_eq!(utp.conversation, "conversation");
465 assert_eq!(utp.faction_id, 1);
466 assert!(utp.plot);
467 assert!(utp.not_blastable);
468 assert!(utp.min1_hp);
469 assert!(utp.key_required);
470 assert!(!utp.lockable);
471 assert!(utp.locked);
472 assert_eq!(utp.open_lock_dc, 28);
473 assert_eq!(utp.open_lock_diff, 1);
474 assert_eq!(utp.open_lock_diff_mod, 1);
475 assert_eq!(utp.key_name, "somekey");
476 assert_eq!(utp.animation_state, 2);
477 assert_eq!(utp.appearance_id, 67);
478 assert_eq!(utp.maximum_hp, 15);
479 assert_eq!(utp.current_hp, 15);
480 assert_eq!(utp.hardness, 5);
481 assert_eq!(utp.fortitude, 16);
482 assert_eq!(utp.reflex, 0);
483 assert_eq!(utp.will, 0);
484 assert_eq!(utp.on_closed, "onclosed");
485 assert_eq!(utp.on_damaged, "ondamaged");
486 assert_eq!(utp.on_death, "ondeath");
487 assert_eq!(utp.on_disarm, "ondisarm");
488 assert_eq!(utp.on_heartbeat, "onheartbeat");
489 assert_eq!(utp.on_inventory, "oninvdisturbed");
490 assert_eq!(utp.on_lock, "onlock");
491 assert_eq!(utp.on_melee_attacked, "onmeleeattacked");
492 assert_eq!(utp.on_open, "onopen");
493 assert_eq!(utp.on_spell_cast_at, "onspellcastat");
494 assert_eq!(utp.on_unlock, "onunlock");
495 assert_eq!(utp.on_used, "onused");
496 assert_eq!(utp.on_user_defined, "onuserdefined");
497 assert_eq!(utp.on_end_dialogue, "onenddialogue");
498 assert!(utp.has_inventory);
499 assert!(utp.party_interact);
500 assert!(utp.is_static);
501 assert!(utp.useable);
502 assert_eq!(utp.comment, "Large standup locker");
503 assert!(utp.interruptable);
504 assert_eq!(utp.portrait_id, 0);
505 assert!(utp.trap_detectable);
506 assert_eq!(utp.trap_detect_dc, 0);
507 assert!(utp.trap_disarmable);
508 assert_eq!(utp.trap_disarm_dc, 15);
509 assert_eq!(utp.trap_flag, 0);
510 assert!(utp.trap_one_shot);
511 assert_eq!(utp.trap_type, 0);
512 assert_eq!(utp.bodybag_id, 0);
513 assert_eq!(utp.type_id, 0);
514 assert_eq!(utp.palette_id, 6);
515
516 assert_eq!(utp.inventory.len(), 2);
517 assert_eq!(utp.inventory[0].inventory_res, "g_w_iongren01");
518 assert!(!utp.inventory[0].droppable);
519 assert_eq!(utp.inventory[1].inventory_res, "g_w_iongren02");
520 assert!(utp.inventory[1].droppable);
521 }
522
523 #[test]
524 fn all_fields_survive_typed_roundtrip() {
525 let utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
526 let bytes = author_utp_to_vec(&utp).expect("write succeeds");
527 let reparsed = read_utp_from_bytes(&bytes).expect("reparse succeeds");
528 assert_eq!(reparsed, utp);
529 }
530
531 #[test]
532 fn typed_edits_roundtrip_through_gff_writer() {
533 let mut utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
534 utp.tag = "SecLocRust".into();
535 utp.open_lock_dc = 33;
536 utp.locked = false;
537 utp.inventory[0].droppable = true;
538 utp.comment = "Rust comment".into();
539 utp.on_open = ResRef::new("k_on_open_new").expect("valid test resref");
540
541 let encoded = author_utp_to_vec(&utp).expect("encode");
542 let reparsed = read_utp_from_bytes(&encoded).expect("decode");
543
544 assert_eq!(reparsed.tag, "SecLocRust");
545 assert_eq!(reparsed.open_lock_dc, 33);
546 assert!(!reparsed.locked);
547 assert!(reparsed.inventory[0].droppable);
548 assert_eq!(reparsed.comment, "Rust comment");
549 assert_eq!(reparsed.on_open, "k_on_open_new");
550 }
551
552 #[test]
553 fn static_defaults_to_inverse_of_useable_when_static_missing() {
554 let mut root = GffStruct::new(-1);
555 root.push_field(gff_label!("Useable"), GffValue::UInt8(1));
556 root.push_field(gff_label!("ItemList"), GffValue::List(Vec::new()));
557 let utp = Utp::from_gff(&Gff::new(*b"UTP ", root)).expect("must parse");
558 assert!(!utp.is_static);
559
560 let mut root2 = GffStruct::new(-1);
561 root2.push_field(gff_label!("Useable"), GffValue::UInt8(0));
562 root2.push_field(gff_label!("ItemList"), GffValue::List(Vec::new()));
563 let utp2 = Utp::from_gff(&Gff::new(*b"UTP ", root2)).expect("must parse");
564 assert!(utp2.is_static);
565 }
566
567 #[test]
568 fn rejects_non_utp_file_type() {
569 let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
570 let err = Utp::from_gff(&gff).expect_err("must fail");
571 assert!(matches!(err, UtpError::UnsupportedFileType(file_type) if file_type == *b"UTC "));
572 }
573
574 #[test]
575 fn read_utp_from_reader_matches_bytes_path() {
576 let mut cursor = Cursor::new(TEST_UTP);
577 let via_reader = read_utp(&mut cursor).expect("reader parse");
578 let via_bytes = read_utp_from_bytes(TEST_UTP).expect("bytes parse");
579 assert_eq!(via_reader.tag, via_bytes.tag);
580 assert_eq!(via_reader.inventory.len(), via_bytes.inventory.len());
581 }
582
583 #[test]
584 fn a_mistyped_item_list_reads_as_empty() {
585 let mut root = GffStruct::new(-1);
586 root.push_field(gff_label!("ItemList"), GffValue::UInt32(7));
587 let gff = Gff::new(*b"UTP ", root);
588
589 let utp = Utp::from_gff(&gff).expect("a mistyped list is not a read failure");
590
591 assert!(utp.inventory.is_empty());
592 }
593
594 #[test]
595 fn write_utp_matches_direct_gff_writer() {
596 let utp = read_utp_from_bytes(TEST_UTP).expect("fixture parse");
597 let from_utp = author_utp_to_vec(&utp).expect("utp encode");
598
599 let gff = utp.to_gff();
600 let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
601 assert_eq!(from_utp, from_gff);
602 }
603
604 #[test]
605 fn schema_field_count() {
606 assert_eq!(Utp::schema().len(), 71);
607 }
608
609 #[test]
610 fn schema_no_duplicate_labels() {
611 let mut labels: Vec<&str> = Utp::schema().iter().map(|f| f.label.as_str()).collect();
612 labels.sort_unstable();
613 let before = labels.len();
614 labels.dedup();
615 assert_eq!(before, labels.len(), "duplicate labels in UTP schema");
616 }
617
618 #[test]
619 fn schema_item_list_carries_its_element() {
620 let item_list = Utp::schema()
621 .iter()
622 .find(|f| f.label.as_str() == "ItemList")
623 .expect("ItemList is declared");
624 let Shape::List { element, .. } = item_list.shape else {
625 panic!("ItemList is a list");
626 };
627 assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 7);
629 }
630}