Skip to main content

rakata_generics/
utp.rs

1//! UTP (`.utp`) typed generic wrapper.
2//!
3//! Placeables are interactive scenery and containers, from footlockers to
4//! command consoles to destructible barricades. They mix static physical
5//! properties such as structural HP and lock difficulty with script bindings.
6//!
7//! ## Field Layout (simplified)
8//! ```text
9//! UTP root struct
10//! +-- TemplateResRef / Tag / LocName / Description
11//! +-- Appearance / Faction / Plot / Invulnerable / Min1HP
12//! +-- Lockable / Locked / KeyRequired / OpenLockDC / CloseLockDC / KeyName
13//! +-- Useable / Static / PartyInteract
14//! +-- HP / CurrentHP / Hardness / Fort / Ref / Will
15//! +-- Script hooks (OnClosed/OnDamaged/...)
16//! +-- ItemList                         (List<Struct>)
17//! |   +-- InventoryRes / Dropable
18//! |   `-- Repos_PosX / Repos_PosY
19//! ```
20//!
21//! ## The lock and saving-throw fields are declared here, not shared
22//!
23//! A placeable and a door carry the same seven lock labels and the same three
24//! saving throws. Every one of those ten is recorded `stamped` here against
25//! `constructed` on the door, so one shared entry would state the wrong
26//! mechanism for one of them. A shared reader is still right, since the value
27//! is the same; the record of how the engine reaches it is not shareable.
28
29use 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/// Typed UTP model built from/to [`Gff`] data.
43///
44/// `LoadScreenID` and `OnFailToOpen` are declared without members: both are
45/// legitimate file content the placeable loader never reads.
46#[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    /// Placeable template resref (`TemplateResRef`).
61    #[gff(TemplateResRef, unexamined)]
62    pub template_resref: ResRef,
63    /// Placeable tag (`Tag`).
64    #[gff(Tag, stamped)]
65    pub tag: String,
66    /// Localized placeable name (`LocName`).
67    #[gff(LocName, stamped)]
68    pub name: GffLocalizedString,
69    /// Localized description (`Description`).
70    #[gff(Description, stamped)]
71    pub description: GffLocalizedString,
72    /// Toolset comment (`Comment`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
73    #[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    /// Conversation resref (`Conversation`).
80    #[gff(Conversation, stamped)]
81    pub conversation: ResRef,
82    /// Faction identifier (`Faction`).
83    #[gff(Faction, stamped)]
84    pub faction_id: u32,
85    /// Appearance identifier (`Appearance`). Engine truncates to a single byte; values above 255 wrap to 0 and break placeable model rendering.
86    #[gff(Appearance, unexamined, range_int = (0, 255))]
87    pub appearance_id: u32,
88    /// Animation state (`AnimationState`).
89    #[gff(AnimationState, stamped)]
90    pub animation_state: u8,
91    /// Animation id (`Animation`).
92    #[gff(Animation, stamped, omit = audited_constant(1784))]
93    pub animation: i32,
94    /// Open-state flag (`Open`).
95    #[gff(Open, stamped, omit = audited_constant(1784))]
96    pub open: bool,
97    /// Whether the object can be locked at all (`Lockable`).
98    #[gff(Lockable, stamped)]
99    pub lockable: bool,
100    /// Whether the object is currently locked (`Locked`).
101    #[gff(Locked, stamped)]
102    pub locked: bool,
103    /// Whether opening requires the named key (`KeyRequired`).
104    #[gff(KeyRequired, stamped)]
105    pub key_required: bool,
106    /// Tag of the key that opens it (`KeyName`).
107    #[gff(KeyName, stamped)]
108    pub key_name: String,
109    /// Whether the key is consumed on use (`AutoRemoveKey`).
110    #[gff(AutoRemoveKey, stamped)]
111    pub auto_remove_key: bool,
112    /// Security DC to pick the lock (`OpenLockDC`).
113    #[gff(OpenLockDC, stamped)]
114    pub open_lock_dc: u8,
115    /// Security DC to relock it (`CloseLockDC`).
116    #[gff(CloseLockDC, stamped)]
117    pub close_lock_dc: u8,
118    /// Open-lock diff (`OpenLockDiff`, K2-oriented field).
119    #[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    /// Open-lock diff modifier (`OpenLockDiffMod`, K2-oriented field).
126    #[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    /// Current hit points (`CurrentHP`). Engine clamps this value to `maximum_hp` on template load.
133    #[gff(CurrentHP, stamped)]
134    pub current_hp: i16,
135    /// Maximum hit points (`HP`).
136    #[gff(HP, stamped)]
137    pub maximum_hp: i16,
138    /// Hardness (`Hardness`).
139    #[gff(Hardness, stamped)]
140    pub hardness: u8,
141    /// Fortitude save (`Fort`).
142    #[gff(Fort, stamped)]
143    pub fortitude: u8,
144    /// Reflex save (`Ref`).
145    #[gff(Ref, stamped)]
146    pub reflex: u8,
147    /// Will save (`Will`).
148    #[gff(Will, stamped)]
149    pub will: u8,
150    /// Plot flag (`Plot`). Forced to `true` at runtime if `is_static` is true.
151    #[gff(Plot, constructed)]
152    pub plot: bool,
153    /// Invulnerable flag (`Invulnerable`).
154    #[gff(Invulnerable, constructed, omit = audited_constant(1784))]
155    pub invulnerable: bool,
156    /// Min-1HP flag (`Min1HP`).
157    #[gff(Min1HP, constructed)]
158    pub min1_hp: bool,
159    /// Not-blastable flag (`NotBlastable`, K2-oriented field).
160    #[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    /// Static flag (`Static`). If missing from the binary, the engine derives this as the inverse of `useable`.
167    #[gff(Static, from_siblings, manual_read, not_a_constant)]
168    pub is_static: bool,
169    /// Useable flag (`Useable`).
170    #[gff(Useable, stamped)]
171    pub useable: bool,
172    /// Party-interact flag (`PartyInteract`).
173    #[gff(PartyInteract, stamped)]
174    pub party_interact: bool,
175    /// Has-inventory flag (`HasInventory`).
176    #[gff(HasInventory, stamped)]
177    pub has_inventory: bool,
178    /// Die-when-empty flag (`DieWhenEmpty`).
179    #[gff(DieWhenEmpty, stamped, omit = audited_constant(1784))]
180    pub die_when_empty: bool,
181    /// Ground-pile flag (`GroundPile`). The engine reads this value but immediately overwrites it with `true` at runtime.
182    #[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    /// Light-state flag (`LightState`).
189    #[gff(LightState, not_a_constant)]
190    pub light_state: bool,
191    /// Interruptable flag (`Interruptable`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
192    #[gff(Interruptable, unexamined)]
193    pub interruptable: bool,
194    /// Portrait ID (`PortraitId`). If `< 0xFFFE`, the engine completely shadows and ignores the string `Portrait` resref field.
195    #[gff(PortraitId, stamped = PORTRAIT_ID_USE_RESREF)]
196    pub portrait_id: u16,
197    /// Portrait resref (`Portrait`). Ignored by the engine if `portrait_id` is `< 0xFFFE`.
198    #[gff(Portrait, stamped)]
199    pub portrait: ResRef,
200    /// Palette ID (`PaletteID`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
201    #[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    /// Body-bag type (`BodyBag`).
208    #[gff(BodyBag, constructed)]
209    pub bodybag_id: u8,
210    /// Type ID (`Type`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
211    #[gff(Type, unexamined)]
212    pub type_id: u8,
213    /// Is-body-bag flag (`IsBodyBag`).
214    #[gff(IsBodyBag, constructed, omit = audited_constant(1784))]
215    pub is_body_bag: bool,
216    /// Is-corpse flag (`IsCorpse`).
217    #[gff(IsCorpse, constructed, omit = audited_constant(1784))]
218    pub is_corpse: bool,
219    /// Trap-detectable flag (`TrapDetectable`).
220    #[gff(TrapDetectable, stamped)]
221    pub trap_detectable: bool,
222    /// Trap detect DC (`TrapDetectDC`).
223    #[gff(TrapDetectDC, stamped)]
224    pub trap_detect_dc: u8,
225    /// Trap-disarmable flag (`TrapDisarmable`).
226    #[gff(TrapDisarmable, stamped)]
227    pub trap_disarmable: bool,
228    /// Trap disarm DC (`DisarmDC`).
229    #[gff(DisarmDC, stamped)]
230    pub trap_disarm_dc: u8,
231    /// Trap flag (`TrapFlag`).
232    #[gff(TrapFlag, stamped)]
233    pub trap_flag: u8,
234    /// Trap one-shot flag (`TrapOneShot`).
235    #[gff(TrapOneShot, stamped)]
236    pub trap_one_shot: bool,
237    /// Trap type (`TrapType`).
238    #[gff(TrapType, constructed = TRAP_TYPE_ABSENT)]
239    pub trap_type: u8,
240    /// On-closed script (`OnClosed`).
241    #[gff(OnClosed, stamped)]
242    pub on_closed: ResRef,
243    /// On-damaged script (`OnDamaged`).
244    #[gff(OnDamaged, stamped)]
245    pub on_damaged: ResRef,
246    /// On-death script (`OnDeath`).
247    #[gff(OnDeath, stamped)]
248    pub on_death: ResRef,
249    /// On-disarm script (`OnDisarm`).
250    #[gff(OnDisarm, stamped)]
251    pub on_disarm: ResRef,
252    /// On-heartbeat script (`OnHeartbeat`).
253    #[gff(OnHeartbeat, stamped)]
254    pub on_heartbeat: ResRef,
255    /// On-inventory-disturbed script (`OnInvDisturbed`).
256    #[gff(OnInvDisturbed, stamped)]
257    pub on_inventory: ResRef,
258    /// On-lock script (`OnLock`).
259    #[gff(OnLock, stamped)]
260    pub on_lock: ResRef,
261    /// On-melee-attacked script (`OnMeleeAttacked`).
262    #[gff(OnMeleeAttacked, stamped)]
263    pub on_melee_attacked: ResRef,
264    /// On-open script (`OnOpen`).
265    #[gff(OnOpen, stamped)]
266    pub on_open: ResRef,
267    /// On-spell-cast-at script (`OnSpellCastAt`).
268    #[gff(OnSpellCastAt, stamped)]
269    pub on_spell_cast_at: ResRef,
270    /// On-unlock script (`OnUnlock`).
271    #[gff(OnUnlock, stamped)]
272    pub on_unlock: ResRef,
273    /// On-used script (`OnUsed`).
274    #[gff(OnUsed, stamped)]
275    pub on_used: ResRef,
276    /// On-user-defined script (`OnUserDefined`).
277    #[gff(OnUserDefined, stamped)]
278    pub on_user_defined: ResRef,
279    /// On-dialog script (`OnDialog`).
280    #[gff(OnDialog, stamped, omit = audited_constant(1784))]
281    pub on_dialog: ResRef,
282    /// On-end-dialogue script (`OnEndDialogue`).
283    #[gff(OnEndDialogue, stamped)]
284    pub on_end_dialogue: ResRef,
285    /// On-trap-triggered script (`OnTrapTriggered`). If empty, the engine falls back to the default script in `traps.2da` keyed by `trap_type`.
286    #[gff(OnTrapTriggered, stamped)]
287    pub on_trap_triggered: ResRef,
288    /// Inventory entries (`ItemList`).
289    #[gff(ItemList, not_a_constant, list = UtpInventoryItem, element_id = positional)]
290    pub inventory: Vec<UtpInventoryItem>,
291}
292
293impl Utp {
294    /// Creates an empty UTP value.
295    pub fn new() -> Self {
296        Self::default()
297    }
298
299    /// Builds typed UTP data from a parsed GFF container.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`UtpError::UnsupportedFileType`] for a container that is
304    /// neither `UTP ` nor the generic `GFF ` form.
305    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        // An absent `Static` is derived from the placeable being usable rather
314        // than from a constant, so presence decides which answer applies. The
315        // derive generates no read for it, which is why the present case is
316        // spelled out too.
317        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    /// Converts this typed UTP value into a GFF container.
326    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/// One UTP inventory item entry from the `ItemList` field.
334///
335/// `Infinite`, `ObjectId` and `Repos_PosY` are declared without members:
336/// the first two are read by the store loader rather than this one, and the
337/// third is a spelling only non-binary sources produce.
338#[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    /// Inventory item resref (`InventoryRes`).
354    #[gff(InventoryRes, unexamined)]
355    pub inventory_res: ResRef,
356    /// Droppable flag (`Dropable`).
357    /// `CSWSItem::LoadDataFromGff` re-reads this with an unconditional
358    /// literal zero, so a container-nested item with the label absent ends up
359    /// undroppable whatever its constructor set. Vanilla omits it there.
360    #[gff(Dropable, stamped, omit = audited_constant(1784))]
361    pub droppable: bool,
362    /// Repository position X (`Repos_PosX`).
363    #[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    /// Repository position Y (`Repos_Posy`).
370    #[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/// Errors produced while reading or writing typed UTP data.
379#[derive(Debug, Error)]
380pub enum UtpError {
381    /// Source file type is not supported by this parser.
382    #[error("unsupported UTP file type: {0:?}")]
383    UnsupportedFileType([u8; 4]),
384    /// Underlying GFF parser/writer error.
385    #[error(transparent)]
386    Gff(#[from] GffBinaryError),
387}
388
389/// Reads typed UTP data from a reader at the current stream position.
390///
391/// # Errors
392///
393/// [`UtpError::Gff`] when the stream is not a readable GFF, and
394/// [`UtpError::UnsupportedFileType`] when it is a GFF of some other format,
395/// carrying the fourcc that was found.
396#[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/// Reads typed UTP data directly from bytes.
406///
407/// # Errors
408///
409/// [`UtpError::Gff`] when `bytes` are not a readable GFF, and
410/// [`UtpError::UnsupportedFileType`] when they are a GFF of some other format,
411/// carrying the fourcc that was found.
412#[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/// Authors the UTP file the typed view describes, into a writer.
422///
423/// # Errors
424///
425/// [`UtpError::Gff`] when the writer fails or a value will not encode. The
426/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
427/// this side.
428#[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/// Authors the UTP file the typed view describes, as bytes.
439///
440/// # Errors
441///
442/// [`UtpError::Gff`] when a value will not encode. Writing into a `Vec` has no
443/// I/O to fail at.
444#[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        // Four members and the three labels no member holds.
636        assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 7);
637    }
638}