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(Repos_PosX, stamped = REPOS_UNPLACED_WORD)]
364    pub repos_pos_x: u16,
365    /// Repository position Y (`Repos_PosY` or legacy `Repos_Posy`).
366    #[gff(Repos_Posy, stamped = REPOS_UNPLACED_WORD)]
367    pub repos_pos_y: u16,
368}
369
370/// Errors produced while reading or writing typed UTP data.
371#[derive(Debug, Error)]
372pub enum UtpError {
373    /// Source file type is not supported by this parser.
374    #[error("unsupported UTP file type: {0:?}")]
375    UnsupportedFileType([u8; 4]),
376    /// Underlying GFF parser/writer error.
377    #[error(transparent)]
378    Gff(#[from] GffBinaryError),
379}
380
381/// Reads typed UTP data from a reader at the current stream position.
382///
383/// # Errors
384///
385/// [`UtpError::Gff`] when the stream is not a readable GFF, and
386/// [`UtpError::UnsupportedFileType`] when it is a GFF of some other format,
387/// carrying the fourcc that was found.
388#[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/// Reads typed UTP data directly from bytes.
398///
399/// # Errors
400///
401/// [`UtpError::Gff`] when `bytes` are not a readable GFF, and
402/// [`UtpError::UnsupportedFileType`] when they are a GFF of some other format,
403/// carrying the fourcc that was found.
404#[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/// Authors the UTP file the typed view describes, into a writer.
414///
415/// # Errors
416///
417/// [`UtpError::Gff`] when the writer fails or a value will not encode. The
418/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
419/// this side.
420#[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/// Authors the UTP file the typed view describes, as bytes.
431///
432/// # Errors
433///
434/// [`UtpError::Gff`] when a value will not encode. Writing into a `Vec` has no
435/// I/O to fail at.
436#[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        // Four members and the three labels no member holds.
628        assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 7);
629    }
630}