rakata_generics/
utp.rs

1//! UTP (`.utp`) typed generic wrapper.
2//!
3//! UTP resources are GFF-backed placeable templates.
4//!
5//! ## Field Layout (simplified)
6//! ```text
7//! UTP root struct
8//! +-- TemplateResRef / Tag / LocName / Description
9//! +-- Appearance / Faction / Plot / Invulnerable / Min1HP
10//! +-- Lockable / Locked / KeyRequired / OpenLockDC / CloseLockDC / KeyName
11//! +-- Useable / Static / PartyInteract
12//! +-- HP / CurrentHP / Hardness / Fort / Ref / Will
13//! +-- Script hooks (OnClosed/OnDamaged/...)
14//! +-- ItemList                         (List<Struct>)
15//! |   +-- InventoryRes / Dropable
16//! |   `-- Repos_PosX / Repos_PosY
17//! ```
18
19use std::io::{Cursor, Read, Write};
20
21use crate::gff_helpers::{
22    get_bool, get_i16, get_i32, get_i8, get_locstring, get_resref, get_string, get_u16, get_u32,
23    get_u8, upsert_field,
24};
25use crate::shared::{CommonTrapScripts, InventoryGridPosition, TrapSettings};
26use rakata_core::{ResRef, StrRef};
27use rakata_formats::{
28    gff_schema::{FieldConstraint, FieldSchema, GffSchema, GffType},
29    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
30    GffValue,
31};
32use thiserror::Error;
33
34/// Typed UTP model built from/to [`Gff`] data.
35#[derive(Debug, Clone, PartialEq)]
36pub struct Utp {
37    /// Placeable template resref (`TemplateResRef`).
38    pub template_resref: ResRef,
39    /// Placeable tag (`Tag`).
40    pub tag: String,
41    /// Localized placeable name (`LocName`).
42    pub name: GffLocalizedString,
43    /// Localized description (`Description`).
44    pub description: GffLocalizedString,
45    /// Toolset comment (`Comment`).
46    pub comment: String,
47    /// Conversation resref (`Conversation`).
48    pub conversation: ResRef,
49    /// Faction identifier (`Faction`).
50    pub faction_id: u32,
51    /// Appearance identifier (`Appearance`).
52    pub appearance_id: u32,
53    /// Animation state (`AnimationState`).
54    pub animation_state: u8,
55    /// Animation id (`Animation`).
56    pub animation: i32,
57    /// Open-state flag (`Open`).
58    pub open: bool,
59    /// Auto-remove-key flag (`AutoRemoveKey`).
60    pub auto_remove_key: bool,
61    /// Key name (`KeyName`).
62    pub key_name: String,
63    /// Key-required flag (`KeyRequired`).
64    pub key_required: bool,
65    /// Lockable flag (`Lockable`).
66    pub lockable: bool,
67    /// Locked flag (`Locked`).
68    pub locked: bool,
69    /// Open lock DC (`OpenLockDC`).
70    pub open_lock_dc: u8,
71    /// Close lock DC (`CloseLockDC`).
72    pub close_lock_dc: u8,
73    /// Open-lock diff (`OpenLockDiff`, K2-oriented field).
74    pub open_lock_diff: u8,
75    /// Open-lock diff modifier (`OpenLockDiffMod`, K2-oriented field).
76    pub open_lock_diff_mod: i8,
77    /// Current hit points (`CurrentHP`).
78    pub current_hp: i16,
79    /// Maximum hit points (`HP`).
80    pub maximum_hp: i16,
81    /// Hardness (`Hardness`).
82    pub hardness: u8,
83    /// Fortitude save (`Fort`).
84    pub fortitude: u8,
85    /// Reflex save (`Ref`).
86    pub reflex: u8,
87    /// Will save (`Will`).
88    pub will: u8,
89    /// Plot flag (`Plot`).
90    pub plot: bool,
91    /// Invulnerable flag (`Invulnerable`).
92    pub invulnerable: bool,
93    /// Min-1HP flag (`Min1HP`).
94    pub min1_hp: bool,
95    /// Not-blastable flag (`NotBlastable`, K2-oriented field).
96    pub not_blastable: bool,
97    /// Static flag (`Static`).
98    pub is_static: bool,
99    /// Useable flag (`Useable`).
100    pub useable: bool,
101    /// Party-interact flag (`PartyInteract`).
102    pub party_interact: bool,
103    /// Has-inventory flag (`HasInventory`).
104    pub has_inventory: bool,
105    /// Die-when-empty flag (`DieWhenEmpty`).
106    pub die_when_empty: bool,
107    /// Ground-pile flag (`GroundPile`).
108    pub ground_pile: bool,
109    /// Light-state flag (`LightState`).
110    pub light_state: bool,
111    /// Interruptable flag (`Interruptable`).
112    pub interruptable: bool,
113    /// Portrait ID (`PortraitId`).
114    pub portrait_id: u16,
115    /// Portrait resref (`Portrait`).
116    pub portrait: ResRef,
117    /// Palette ID (`PaletteID`).
118    pub palette_id: u8,
119    /// Body-bag type (`BodyBag`).
120    pub bodybag_id: u8,
121    /// Type ID (`Type`).
122    pub type_id: u8,
123    /// Is-body-bag flag (`IsBodyBag`).
124    pub is_body_bag: bool,
125    /// Is-corpse flag (`IsCorpse`).
126    pub is_corpse: bool,
127    /// Trap-detectable flag (`TrapDetectable`).
128    pub trap_detectable: bool,
129    /// Trap detect DC (`TrapDetectDC`).
130    pub trap_detect_dc: u8,
131    /// Trap-disarmable flag (`TrapDisarmable`).
132    pub trap_disarmable: bool,
133    /// Trap disarm DC (`DisarmDC`).
134    pub trap_disarm_dc: u8,
135    /// Trap flag (`TrapFlag`).
136    pub trap_flag: u8,
137    /// Trap one-shot flag (`TrapOneShot`).
138    pub trap_one_shot: bool,
139    /// Trap type (`TrapType`).
140    pub trap_type: u8,
141    /// On-closed script (`OnClosed`).
142    pub on_closed: ResRef,
143    /// On-damaged script (`OnDamaged`).
144    pub on_damaged: ResRef,
145    /// On-death script (`OnDeath`).
146    pub on_death: ResRef,
147    /// On-disarm script (`OnDisarm`).
148    pub on_disarm: ResRef,
149    /// On-heartbeat script (`OnHeartbeat`).
150    pub on_heartbeat: ResRef,
151    /// On-inventory-disturbed script (`OnInvDisturbed`).
152    pub on_inventory: ResRef,
153    /// On-lock script (`OnLock`).
154    pub on_lock: ResRef,
155    /// On-melee-attacked script (`OnMeleeAttacked`).
156    pub on_melee_attacked: ResRef,
157    /// On-open script (`OnOpen`).
158    pub on_open: ResRef,
159    /// On-spell-cast-at script (`OnSpellCastAt`).
160    pub on_spell_cast_at: ResRef,
161    /// On-unlock script (`OnUnlock`).
162    pub on_unlock: ResRef,
163    /// On-used script (`OnUsed`).
164    pub on_used: ResRef,
165    /// On-user-defined script (`OnUserDefined`).
166    pub on_user_defined: ResRef,
167    /// On-dialog script (`OnDialog`).
168    pub on_dialog: ResRef,
169    /// On-end-dialogue script (`OnEndDialogue`).
170    pub on_end_dialogue: ResRef,
171    /// On-trap-triggered script (`OnTrapTriggered`).
172    pub on_trap_triggered: ResRef,
173    /// On-fail-to-open script (`OnFailToOpen`).
174    pub on_fail_to_open: ResRef,
175    /// Inventory entries (`ItemList`).
176    pub inventory: Vec<UtpInventoryItem>,
177}
178
179impl Default for Utp {
180    fn default() -> Self {
181        Self {
182            template_resref: ResRef::blank(),
183            tag: String::new(),
184            name: GffLocalizedString::new(StrRef::invalid()),
185            description: GffLocalizedString::new(StrRef::invalid()),
186            comment: String::new(),
187            conversation: ResRef::blank(),
188            faction_id: 0,
189            appearance_id: 0,
190            animation_state: 0,
191            animation: 0,
192            open: false,
193            auto_remove_key: false,
194            key_name: String::new(),
195            key_required: false,
196            lockable: false,
197            locked: false,
198            open_lock_dc: 0,
199            close_lock_dc: 0,
200            open_lock_diff: 0,
201            open_lock_diff_mod: 0,
202            current_hp: 0,
203            maximum_hp: 0,
204            hardness: 0,
205            fortitude: 0,
206            reflex: 0,
207            will: 0,
208            plot: false,
209            invulnerable: false,
210            min1_hp: false,
211            not_blastable: false,
212            is_static: false,
213            useable: false,
214            party_interact: false,
215            has_inventory: false,
216            die_when_empty: false,
217            ground_pile: true,
218            light_state: false,
219            interruptable: false,
220            portrait_id: 0xffff,
221            portrait: ResRef::blank(),
222            palette_id: 0,
223            bodybag_id: 0,
224            type_id: 0,
225            is_body_bag: false,
226            is_corpse: false,
227            trap_detectable: false,
228            trap_detect_dc: 0,
229            trap_disarmable: false,
230            trap_disarm_dc: 0,
231            trap_flag: false.into(),
232            trap_one_shot: false,
233            trap_type: 0,
234            on_closed: ResRef::blank(),
235            on_damaged: ResRef::blank(),
236            on_death: ResRef::blank(),
237            on_disarm: ResRef::blank(),
238            on_heartbeat: ResRef::blank(),
239            on_inventory: ResRef::blank(),
240            on_lock: ResRef::blank(),
241            on_melee_attacked: ResRef::blank(),
242            on_open: ResRef::blank(),
243            on_spell_cast_at: ResRef::blank(),
244            on_unlock: ResRef::blank(),
245            on_used: ResRef::blank(),
246            on_user_defined: ResRef::blank(),
247            on_dialog: ResRef::blank(),
248            on_end_dialogue: ResRef::blank(),
249            on_trap_triggered: ResRef::blank(),
250            on_fail_to_open: ResRef::blank(),
251            inventory: Vec::new(),
252        }
253    }
254}
255
256impl Utp {
257    /// Creates an empty UTP value.
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    /// Returns trap-related settings as a shared typed block.
263    pub fn trap_settings(&self) -> TrapSettings {
264        TrapSettings {
265            detectable: self.trap_detectable,
266            detect_dc: self.trap_detect_dc,
267            disarmable: self.trap_disarmable,
268            disarm_dc: self.trap_disarm_dc,
269            flag: self.trap_flag,
270            one_shot: self.trap_one_shot,
271            trap_type: self.trap_type,
272        }
273    }
274
275    /// Applies trap-related settings from a shared typed block.
276    pub fn set_trap_settings(&mut self, trap: TrapSettings) {
277        self.trap_detectable = trap.detectable;
278        self.trap_detect_dc = trap.detect_dc;
279        self.trap_disarmable = trap.disarmable;
280        self.trap_disarm_dc = trap.disarm_dc;
281        self.trap_flag = trap.flag;
282        self.trap_one_shot = trap.one_shot;
283        self.trap_type = trap.trap_type;
284    }
285
286    /// Returns the common trap-script hooks as a shared typed bundle.
287    pub fn common_trap_scripts(&self) -> CommonTrapScripts {
288        CommonTrapScripts {
289            on_closed: self.on_closed,
290            on_damaged: self.on_damaged,
291            on_death: self.on_death,
292            on_disarm: self.on_disarm,
293            on_heartbeat: self.on_heartbeat,
294            on_lock: self.on_lock,
295            on_melee_attacked: self.on_melee_attacked,
296            on_open: self.on_open,
297            on_spell_cast_at: self.on_spell_cast_at,
298            on_trap_triggered: self.on_trap_triggered,
299            on_unlock: self.on_unlock,
300            on_user_defined: self.on_user_defined,
301            on_fail_to_open: self.on_fail_to_open,
302        }
303    }
304
305    /// Applies the common trap-script hooks from a shared typed bundle.
306    pub fn set_common_trap_scripts(&mut self, scripts: CommonTrapScripts) {
307        self.on_closed = scripts.on_closed;
308        self.on_damaged = scripts.on_damaged;
309        self.on_death = scripts.on_death;
310        self.on_disarm = scripts.on_disarm;
311        self.on_heartbeat = scripts.on_heartbeat;
312        self.on_lock = scripts.on_lock;
313        self.on_melee_attacked = scripts.on_melee_attacked;
314        self.on_open = scripts.on_open;
315        self.on_spell_cast_at = scripts.on_spell_cast_at;
316        self.on_trap_triggered = scripts.on_trap_triggered;
317        self.on_unlock = scripts.on_unlock;
318        self.on_user_defined = scripts.on_user_defined;
319        self.on_fail_to_open = scripts.on_fail_to_open;
320    }
321
322    /// Builds typed UTP data from a parsed GFF container.
323    pub fn from_gff(gff: &Gff) -> Result<Self, UtpError> {
324        if gff.file_type != *b"UTP " && gff.file_type != *b"GFF " {
325            return Err(UtpError::UnsupportedFileType(gff.file_type));
326        }
327
328        let root = &gff.root;
329
330        let inventory = match root.field("ItemList") {
331            Some(GffValue::List(item_structs)) => item_structs
332                .iter()
333                .map(UtpInventoryItem::from_struct)
334                .collect::<Vec<_>>(),
335            Some(_) => {
336                return Err(UtpError::TypeMismatch {
337                    field: "ItemList",
338                    expected: "List",
339                });
340            }
341            None => Vec::new(),
342        };
343
344        let useable = get_bool(root, "Useable").unwrap_or(false);
345        let plot = get_bool(root, "Plot").unwrap_or(false);
346
347        // K1 loader fallback: when `Static` is missing, runtime defaults to
348        // `!Useable` for template-driven loads.
349        let is_static = match root.field("Static") {
350            Some(_) => get_bool(root, "Static").unwrap_or(false),
351            None => !useable,
352        };
353
354        // K1 loader fallback: if `Invulnerable` is missing, it falls back to
355        // the plot-state byte path.
356        let invulnerable = match root.field("Invulnerable") {
357            Some(_) => get_bool(root, "Invulnerable").unwrap_or(plot),
358            None => plot,
359        };
360
361        // TODO(rakata-generics/utp): extend typed parity for remaining runtime
362        // state derivations (`PostProcess` side effects and inventory acquire
363        // semantics) once fixture+evidence requirements are defined.
364        let trap = TrapSettings::read(|label| get_bool(root, label), |label| get_u8(root, label));
365        let common_scripts = CommonTrapScripts::read(|label| get_resref(root, label));
366
367        Ok(Self {
368            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
369            tag: get_string(root, "Tag").unwrap_or_default(),
370            name: get_locstring(root, "LocName")
371                .cloned()
372                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
373            description: get_locstring(root, "Description")
374                .cloned()
375                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
376            comment: get_string(root, "Comment").unwrap_or_default(),
377            conversation: get_resref(root, "Conversation").unwrap_or_default(),
378            faction_id: get_u32(root, "Faction").unwrap_or(0),
379            appearance_id: get_u32(root, "Appearance").unwrap_or(0),
380            animation_state: get_u8(root, "AnimationState").unwrap_or(0),
381            animation: get_i32(root, "Animation").unwrap_or(0),
382            open: get_bool(root, "Open").unwrap_or(false),
383            auto_remove_key: get_bool(root, "AutoRemoveKey").unwrap_or(false),
384            key_name: get_string(root, "KeyName").unwrap_or_default(),
385            key_required: get_bool(root, "KeyRequired").unwrap_or(false),
386            lockable: get_bool(root, "Lockable").unwrap_or(false),
387            locked: get_bool(root, "Locked").unwrap_or(false),
388            open_lock_dc: get_u8(root, "OpenLockDC").unwrap_or(0),
389            close_lock_dc: get_u8(root, "CloseLockDC").unwrap_or(0),
390            open_lock_diff: get_u8(root, "OpenLockDiff").unwrap_or(0),
391            open_lock_diff_mod: get_i8(root, "OpenLockDiffMod").unwrap_or(0),
392            current_hp: get_i16(root, "CurrentHP").unwrap_or(0),
393            maximum_hp: get_i16(root, "HP").unwrap_or(0),
394            hardness: get_u8(root, "Hardness").unwrap_or(0),
395            fortitude: get_u8(root, "Fort").unwrap_or(0),
396            reflex: get_u8(root, "Ref").unwrap_or(0),
397            will: get_u8(root, "Will").unwrap_or(0),
398            plot,
399            invulnerable,
400            min1_hp: get_bool(root, "Min1HP").unwrap_or(false),
401            not_blastable: get_bool(root, "NotBlastable").unwrap_or(false),
402            is_static,
403            useable,
404            party_interact: get_bool(root, "PartyInteract").unwrap_or(false),
405            has_inventory: get_bool(root, "HasInventory").unwrap_or(false),
406            die_when_empty: get_bool(root, "DieWhenEmpty").unwrap_or(false),
407            ground_pile: get_bool(root, "GroundPile").unwrap_or(true),
408            light_state: get_bool(root, "LightState").unwrap_or(false),
409            interruptable: get_bool(root, "Interruptable").unwrap_or(false),
410            portrait_id: get_u16(root, "PortraitId").unwrap_or(0xffff),
411            portrait: get_resref(root, "Portrait").unwrap_or_default(),
412            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
413            bodybag_id: get_u8(root, "BodyBag").unwrap_or(0),
414            type_id: get_u8(root, "Type").unwrap_or(0),
415            is_body_bag: get_bool(root, "IsBodyBag").unwrap_or(false),
416            is_corpse: get_bool(root, "IsCorpse").unwrap_or(false),
417            trap_detectable: trap.detectable,
418            trap_detect_dc: trap.detect_dc,
419            trap_disarmable: trap.disarmable,
420            trap_disarm_dc: trap.disarm_dc,
421            trap_flag: trap.flag,
422            trap_one_shot: trap.one_shot,
423            trap_type: trap.trap_type,
424            on_closed: common_scripts.on_closed,
425            on_damaged: common_scripts.on_damaged,
426            on_death: common_scripts.on_death,
427            on_disarm: common_scripts.on_disarm,
428            on_heartbeat: common_scripts.on_heartbeat,
429            on_inventory: get_resref(root, "OnInvDisturbed").unwrap_or_default(),
430            on_lock: common_scripts.on_lock,
431            on_melee_attacked: common_scripts.on_melee_attacked,
432            on_open: common_scripts.on_open,
433            on_spell_cast_at: common_scripts.on_spell_cast_at,
434            on_unlock: common_scripts.on_unlock,
435            on_used: get_resref(root, "OnUsed").unwrap_or_default(),
436            on_user_defined: common_scripts.on_user_defined,
437            on_dialog: get_resref(root, "OnDialog").unwrap_or_default(),
438            on_end_dialogue: get_resref(root, "OnEndDialogue").unwrap_or_default(),
439            on_trap_triggered: common_scripts.on_trap_triggered,
440            on_fail_to_open: common_scripts.on_fail_to_open,
441            inventory,
442        })
443    }
444
445    /// Converts this typed UTP value into a GFF container.
446    pub fn to_gff(&self) -> Gff {
447        let mut root = GffStruct::new(-1);
448
449        upsert_field(
450            &mut root,
451            "TemplateResRef",
452            GffValue::ResRef(self.template_resref),
453        );
454        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
455        upsert_field(
456            &mut root,
457            "LocName",
458            GffValue::LocalizedString(self.name.clone()),
459        );
460        upsert_field(
461            &mut root,
462            "Description",
463            GffValue::LocalizedString(self.description.clone()),
464        );
465        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
466        upsert_field(
467            &mut root,
468            "Conversation",
469            GffValue::ResRef(self.conversation),
470        );
471
472        upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
473        upsert_field(
474            &mut root,
475            "Appearance",
476            GffValue::UInt32(self.appearance_id),
477        );
478        upsert_field(
479            &mut root,
480            "AnimationState",
481            GffValue::UInt8(self.animation_state),
482        );
483        upsert_field(&mut root, "Animation", GffValue::Int32(self.animation));
484        upsert_field(&mut root, "Open", GffValue::UInt8(u8::from(self.open)));
485
486        upsert_field(
487            &mut root,
488            "AutoRemoveKey",
489            GffValue::UInt8(u8::from(self.auto_remove_key)),
490        );
491        upsert_field(
492            &mut root,
493            "KeyName",
494            GffValue::String(self.key_name.clone()),
495        );
496        upsert_field(
497            &mut root,
498            "KeyRequired",
499            GffValue::UInt8(u8::from(self.key_required)),
500        );
501        upsert_field(
502            &mut root,
503            "Lockable",
504            GffValue::UInt8(u8::from(self.lockable)),
505        );
506        upsert_field(&mut root, "Locked", GffValue::UInt8(u8::from(self.locked)));
507        upsert_field(&mut root, "OpenLockDC", GffValue::UInt8(self.open_lock_dc));
508        upsert_field(
509            &mut root,
510            "CloseLockDC",
511            GffValue::UInt8(self.close_lock_dc),
512        );
513        upsert_field(
514            &mut root,
515            "OpenLockDiff",
516            GffValue::UInt8(self.open_lock_diff),
517        );
518        upsert_field(
519            &mut root,
520            "OpenLockDiffMod",
521            GffValue::Int8(self.open_lock_diff_mod),
522        );
523
524        upsert_field(&mut root, "CurrentHP", GffValue::Int16(self.current_hp));
525        upsert_field(&mut root, "HP", GffValue::Int16(self.maximum_hp));
526        upsert_field(&mut root, "Hardness", GffValue::UInt8(self.hardness));
527        upsert_field(&mut root, "Fort", GffValue::UInt8(self.fortitude));
528        upsert_field(&mut root, "Ref", GffValue::UInt8(self.reflex));
529        upsert_field(&mut root, "Will", GffValue::UInt8(self.will));
530
531        upsert_field(&mut root, "Plot", GffValue::UInt8(u8::from(self.plot)));
532        upsert_field(
533            &mut root,
534            "Invulnerable",
535            GffValue::UInt8(u8::from(self.invulnerable)),
536        );
537        upsert_field(&mut root, "Min1HP", GffValue::UInt8(u8::from(self.min1_hp)));
538        upsert_field(
539            &mut root,
540            "NotBlastable",
541            GffValue::UInt8(u8::from(self.not_blastable)),
542        );
543        upsert_field(
544            &mut root,
545            "Static",
546            GffValue::UInt8(u8::from(self.is_static)),
547        );
548        upsert_field(
549            &mut root,
550            "Useable",
551            GffValue::UInt8(u8::from(self.useable)),
552        );
553        upsert_field(
554            &mut root,
555            "PartyInteract",
556            GffValue::UInt8(u8::from(self.party_interact)),
557        );
558        upsert_field(
559            &mut root,
560            "HasInventory",
561            GffValue::UInt8(u8::from(self.has_inventory)),
562        );
563        upsert_field(
564            &mut root,
565            "DieWhenEmpty",
566            GffValue::UInt8(u8::from(self.die_when_empty)),
567        );
568        upsert_field(
569            &mut root,
570            "GroundPile",
571            GffValue::UInt8(u8::from(self.ground_pile)),
572        );
573        upsert_field(
574            &mut root,
575            "LightState",
576            GffValue::UInt8(u8::from(self.light_state)),
577        );
578        upsert_field(
579            &mut root,
580            "Interruptable",
581            GffValue::UInt8(u8::from(self.interruptable)),
582        );
583
584        upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
585        upsert_field(&mut root, "Portrait", GffValue::ResRef(self.portrait));
586        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
587        upsert_field(&mut root, "BodyBag", GffValue::UInt8(self.bodybag_id));
588        upsert_field(&mut root, "Type", GffValue::UInt8(self.type_id));
589        upsert_field(
590            &mut root,
591            "IsBodyBag",
592            GffValue::UInt8(u8::from(self.is_body_bag)),
593        );
594        upsert_field(
595            &mut root,
596            "IsCorpse",
597            GffValue::UInt8(u8::from(self.is_corpse)),
598        );
599
600        self.trap_settings()
601            .write(|label, value| upsert_field(&mut root, label, value));
602        self.common_trap_scripts()
603            .write(|label, value| upsert_field(&mut root, label, value));
604        upsert_field(
605            &mut root,
606            "OnInvDisturbed",
607            GffValue::ResRef(self.on_inventory),
608        );
609        upsert_field(&mut root, "OnUsed", GffValue::ResRef(self.on_used));
610        upsert_field(&mut root, "OnDialog", GffValue::ResRef(self.on_dialog));
611        upsert_field(
612            &mut root,
613            "OnEndDialogue",
614            GffValue::ResRef(self.on_end_dialogue),
615        );
616        let item_structs = self
617            .inventory
618            .iter()
619            .map(UtpInventoryItem::to_struct)
620            .collect::<Vec<GffStruct>>();
621        upsert_field(&mut root, "ItemList", GffValue::List(item_structs));
622
623        Gff::new(*b"UTP ", root)
624    }
625}
626
627/// One UTP inventory item entry from the `ItemList` field.
628#[derive(Debug, Clone, PartialEq)]
629pub struct UtpInventoryItem {
630    /// Inventory item resref (`InventoryRes`).
631    pub inventory_res: ResRef,
632    /// Droppable flag (`Dropable`).
633    pub droppable: bool,
634    /// Repository position X (`Repos_PosX`).
635    pub repos_pos_x: u16,
636    /// Repository position Y (`Repos_PosY` or legacy `Repos_Posy`).
637    pub repos_pos_y: u16,
638}
639
640impl UtpInventoryItem {
641    fn from_struct(structure: &GffStruct) -> Self {
642        Self {
643            inventory_res: get_resref(structure, "InventoryRes").unwrap_or_default(),
644            droppable: get_bool(structure, "Dropable").unwrap_or(false),
645            repos_pos_x: get_u16(structure, "Repos_PosX").unwrap_or(0),
646            repos_pos_y: get_u16(structure, "Repos_PosY")
647                .or_else(|| get_u16(structure, "Repos_Posy"))
648                .unwrap_or(0),
649        }
650    }
651
652    fn to_struct(&self) -> GffStruct {
653        let mut structure = GffStruct::new(0);
654
655        upsert_field(
656            &mut structure,
657            "InventoryRes",
658            GffValue::ResRef(self.inventory_res),
659        );
660        upsert_field(
661            &mut structure,
662            "Dropable",
663            GffValue::UInt8(u8::from(self.droppable)),
664        );
665        upsert_field(
666            &mut structure,
667            "Repos_PosX",
668            GffValue::UInt16(self.repos_pos_x),
669        );
670        upsert_field(
671            &mut structure,
672            "Repos_PosY",
673            GffValue::UInt16(self.repos_pos_y),
674        );
675
676        structure
677    }
678
679    /// Returns this item's repository/grid position as a shared typed value.
680    pub fn position(&self) -> InventoryGridPosition {
681        InventoryGridPosition {
682            x: self.repos_pos_x,
683            y: self.repos_pos_y,
684        }
685    }
686
687    /// Applies repository/grid position from a shared typed value.
688    pub fn set_position(&mut self, position: InventoryGridPosition) {
689        self.repos_pos_x = position.x;
690        self.repos_pos_y = position.y;
691    }
692}
693
694/// Errors produced while reading or writing typed UTP data.
695#[derive(Debug, Error)]
696pub enum UtpError {
697    /// Source file type is not supported by this parser.
698    #[error("unsupported UTP file type: {0:?}")]
699    UnsupportedFileType([u8; 4]),
700    /// A required container field had an unexpected runtime type.
701    #[error("UTP field `{field}` has incompatible type (expected {expected})")]
702    TypeMismatch {
703        /// Field label where mismatch occurred.
704        field: &'static str,
705        /// Expected runtime value kind.
706        expected: &'static str,
707    },
708    /// Underlying GFF parser/writer error.
709    #[error(transparent)]
710    Gff(#[from] GffBinaryError),
711}
712
713/// Reads typed UTP data from a reader at the current stream position.
714#[cfg_attr(
715    feature = "tracing",
716    tracing::instrument(level = "debug", skip(reader))
717)]
718pub fn read_utp<R: Read>(reader: &mut R) -> Result<Utp, UtpError> {
719    let gff = read_gff(reader)?;
720    Utp::from_gff(&gff)
721}
722
723/// Reads typed UTP data directly from bytes.
724#[cfg_attr(
725    feature = "tracing",
726    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
727)]
728pub fn read_utp_from_bytes(bytes: &[u8]) -> Result<Utp, UtpError> {
729    let gff = read_gff_from_bytes(bytes)?;
730    Utp::from_gff(&gff)
731}
732
733/// Writes typed UTP data to an output writer.
734#[cfg_attr(
735    feature = "tracing",
736    tracing::instrument(level = "debug", skip(writer, utp))
737)]
738pub fn write_utp<W: Write>(writer: &mut W, utp: &Utp) -> Result<(), UtpError> {
739    let gff = utp.to_gff();
740    write_gff(writer, &gff)?;
741    Ok(())
742}
743
744/// Serializes typed UTP data into a byte vector.
745#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utp)))]
746pub fn write_utp_to_vec(utp: &Utp) -> Result<Vec<u8>, UtpError> {
747    let mut cursor = Cursor::new(Vec::new());
748    write_utp(&mut cursor, utp)?;
749    Ok(cursor.into_inner())
750}
751
752/// UTP `ItemList` entry child schema.
753static ITEM_LIST_CHILDREN: &[FieldSchema] = &[
754    FieldSchema {
755        label: "InventoryRes",
756        expected_type: GffType::ResRef,
757        required: false,
758        children: None,
759        constraint: None,
760    },
761    FieldSchema {
762        label: "Infinite",
763        expected_type: GffType::UInt8,
764        required: false,
765        children: None,
766        constraint: None,
767    },
768    FieldSchema {
769        label: "ObjectId",
770        expected_type: GffType::UInt32,
771        required: false,
772        children: None,
773        constraint: None,
774    },
775    FieldSchema {
776        label: "Dropable",
777        expected_type: GffType::UInt8,
778        required: false,
779        children: None,
780        constraint: None,
781    },
782    FieldSchema {
783        label: "Repos_PosX",
784        expected_type: GffType::UInt16,
785        required: false,
786        children: None,
787        constraint: None,
788    },
789    FieldSchema {
790        label: "Repos_PosY",
791        expected_type: GffType::UInt16,
792        required: false,
793        children: None,
794        constraint: None,
795    },
796    FieldSchema {
797        label: "Repos_Posy",
798        expected_type: GffType::UInt16,
799        required: false,
800        children: None,
801        constraint: None,
802    },
803];
804
805impl GffSchema for Utp {
806    fn schema() -> &'static [FieldSchema] {
807        static SCHEMA: &[FieldSchema] = &[
808            // --- Identity ---
809            FieldSchema {
810                label: "Tag",
811                expected_type: GffType::String,
812                required: false,
813                children: None,
814                constraint: None,
815            },
816            FieldSchema {
817                label: "LocName",
818                expected_type: GffType::LocalizedString,
819                required: false,
820                children: None,
821                constraint: None,
822            },
823            FieldSchema {
824                label: "Description",
825                expected_type: GffType::LocalizedString,
826                required: false,
827                children: None,
828                constraint: None,
829            },
830            FieldSchema {
831                label: "Conversation",
832                expected_type: GffType::ResRef,
833                required: false,
834                children: None,
835                constraint: None,
836            },
837            FieldSchema {
838                label: "Faction",
839                expected_type: GffType::UInt32,
840                required: false,
841                children: None,
842                constraint: None,
843            },
844            // --- Appearance / state ---
845            FieldSchema {
846                label: "Appearance",
847                expected_type: GffType::UInt32,
848                required: false,
849                children: None,
850                constraint: Some(FieldConstraint::RangeInt(0, 255)),
851            },
852            FieldSchema {
853                label: "AnimationState",
854                expected_type: GffType::UInt8,
855                required: false,
856                children: None,
857                constraint: None,
858            },
859            FieldSchema {
860                label: "Open",
861                expected_type: GffType::UInt8,
862                required: false,
863                children: None,
864                constraint: None,
865            },
866            // --- Combat / durability ---
867            FieldSchema {
868                label: "HP",
869                expected_type: GffType::Int16,
870                required: false,
871                children: None,
872                constraint: None,
873            },
874            FieldSchema {
875                label: "CurrentHP",
876                expected_type: GffType::Int16,
877                required: false,
878                children: None,
879                constraint: None,
880            },
881            FieldSchema {
882                label: "Hardness",
883                expected_type: GffType::UInt8,
884                required: false,
885                children: None,
886                constraint: None,
887            },
888            FieldSchema {
889                label: "Fort",
890                expected_type: GffType::UInt8,
891                required: false,
892                children: None,
893                constraint: None,
894            },
895            FieldSchema {
896                label: "Ref",
897                expected_type: GffType::UInt8,
898                required: false,
899                children: None,
900                constraint: None,
901            },
902            FieldSchema {
903                label: "Will",
904                expected_type: GffType::UInt8,
905                required: false,
906                children: None,
907                constraint: None,
908            },
909            // --- Flags ---
910            FieldSchema {
911                label: "Plot",
912                expected_type: GffType::UInt8,
913                required: false,
914                children: None,
915                constraint: None,
916            },
917            FieldSchema {
918                label: "Useable",
919                expected_type: GffType::UInt8,
920                required: false,
921                children: None,
922                constraint: None,
923            },
924            FieldSchema {
925                label: "Static",
926                expected_type: GffType::UInt8,
927                required: false,
928                children: None,
929                constraint: None,
930            },
931            FieldSchema {
932                label: "Invulnerable",
933                expected_type: GffType::UInt8,
934                required: false,
935                children: None,
936                constraint: None,
937            },
938            FieldSchema {
939                label: "Min1HP",
940                expected_type: GffType::UInt8,
941                required: false,
942                children: None,
943                constraint: None,
944            },
945            FieldSchema {
946                label: "PartyInteract",
947                expected_type: GffType::UInt8,
948                required: false,
949                children: None,
950                constraint: None,
951            },
952            FieldSchema {
953                label: "HasInventory",
954                expected_type: GffType::UInt8,
955                required: false,
956                children: None,
957                constraint: None,
958            },
959            FieldSchema {
960                label: "DieWhenEmpty",
961                expected_type: GffType::UInt8,
962                required: false,
963                children: None,
964                constraint: None,
965            },
966            FieldSchema {
967                label: "GroundPile",
968                expected_type: GffType::UInt8,
969                required: false,
970                children: None,
971                constraint: None,
972            },
973            FieldSchema {
974                label: "BodyBag",
975                expected_type: GffType::UInt8,
976                required: false,
977                children: None,
978                constraint: None,
979            },
980            FieldSchema {
981                label: "LightState",
982                expected_type: GffType::UInt8,
983                required: false,
984                children: None,
985                constraint: None,
986            },
987            // --- Lock / key ---
988            FieldSchema {
989                label: "Locked",
990                expected_type: GffType::UInt8,
991                required: false,
992                children: None,
993                constraint: None,
994            },
995            FieldSchema {
996                label: "Lockable",
997                expected_type: GffType::UInt8,
998                required: false,
999                children: None,
1000                constraint: None,
1001            },
1002            FieldSchema {
1003                label: "OpenLockDC",
1004                expected_type: GffType::UInt8,
1005                required: false,
1006                children: None,
1007                constraint: None,
1008            },
1009            FieldSchema {
1010                label: "CloseLockDC",
1011                expected_type: GffType::UInt8,
1012                required: false,
1013                children: None,
1014                constraint: None,
1015            },
1016            FieldSchema {
1017                label: "KeyName",
1018                expected_type: GffType::String,
1019                required: false,
1020                children: None,
1021                constraint: None,
1022            },
1023            FieldSchema {
1024                label: "KeyRequired",
1025                expected_type: GffType::UInt8,
1026                required: false,
1027                children: None,
1028                constraint: None,
1029            },
1030            FieldSchema {
1031                label: "AutoRemoveKey",
1032                expected_type: GffType::UInt8,
1033                required: false,
1034                children: None,
1035                constraint: None,
1036            },
1037            // --- Portrait ---
1038            FieldSchema {
1039                label: "PortraitId",
1040                expected_type: GffType::UInt16,
1041                required: false,
1042                children: None,
1043                constraint: None,
1044            },
1045            FieldSchema {
1046                label: "Portrait",
1047                expected_type: GffType::ResRef,
1048                required: false,
1049                children: None,
1050                constraint: None,
1051            },
1052            // --- Misc ---
1053            FieldSchema {
1054                label: "LoadScreenID",
1055                expected_type: GffType::UInt16,
1056                required: false,
1057                children: None,
1058                constraint: None,
1059            },
1060            // --- Trap fields (7) ---
1061            FieldSchema {
1062                label: "TrapType",
1063                expected_type: GffType::UInt8,
1064                required: false,
1065                children: None,
1066                constraint: None,
1067            },
1068            FieldSchema {
1069                label: "TrapDisarmable",
1070                expected_type: GffType::UInt8,
1071                required: false,
1072                children: None,
1073                constraint: None,
1074            },
1075            FieldSchema {
1076                label: "TrapDetectable",
1077                expected_type: GffType::UInt8,
1078                required: false,
1079                children: None,
1080                constraint: None,
1081            },
1082            FieldSchema {
1083                label: "DisarmDC",
1084                expected_type: GffType::UInt8,
1085                required: false,
1086                children: None,
1087                constraint: None,
1088            },
1089            FieldSchema {
1090                label: "TrapDetectDC",
1091                expected_type: GffType::UInt8,
1092                required: false,
1093                children: None,
1094                constraint: None,
1095            },
1096            FieldSchema {
1097                label: "TrapFlag",
1098                expected_type: GffType::UInt8,
1099                required: false,
1100                children: None,
1101                constraint: None,
1102            },
1103            FieldSchema {
1104                label: "TrapOneShot",
1105                expected_type: GffType::UInt8,
1106                required: false,
1107                children: None,
1108                constraint: None,
1109            },
1110            // --- Scripts (16) ---
1111            FieldSchema {
1112                label: "OnClosed",
1113                expected_type: GffType::ResRef,
1114                required: false,
1115                children: None,
1116                constraint: None,
1117            },
1118            FieldSchema {
1119                label: "OnDamaged",
1120                expected_type: GffType::ResRef,
1121                required: false,
1122                children: None,
1123                constraint: None,
1124            },
1125            FieldSchema {
1126                label: "OnDeath",
1127                expected_type: GffType::ResRef,
1128                required: false,
1129                children: None,
1130                constraint: None,
1131            },
1132            FieldSchema {
1133                label: "OnDisarm",
1134                expected_type: GffType::ResRef,
1135                required: false,
1136                children: None,
1137                constraint: None,
1138            },
1139            FieldSchema {
1140                label: "OnHeartbeat",
1141                expected_type: GffType::ResRef,
1142                required: false,
1143                children: None,
1144                constraint: None,
1145            },
1146            FieldSchema {
1147                label: "OnInvDisturbed",
1148                expected_type: GffType::ResRef,
1149                required: false,
1150                children: None,
1151                constraint: None,
1152            },
1153            FieldSchema {
1154                label: "OnLock",
1155                expected_type: GffType::ResRef,
1156                required: false,
1157                children: None,
1158                constraint: None,
1159            },
1160            FieldSchema {
1161                label: "OnMeleeAttacked",
1162                expected_type: GffType::ResRef,
1163                required: false,
1164                children: None,
1165                constraint: None,
1166            },
1167            FieldSchema {
1168                label: "OnOpen",
1169                expected_type: GffType::ResRef,
1170                required: false,
1171                children: None,
1172                constraint: None,
1173            },
1174            FieldSchema {
1175                label: "OnSpellCastAt",
1176                expected_type: GffType::ResRef,
1177                required: false,
1178                children: None,
1179                constraint: None,
1180            },
1181            FieldSchema {
1182                label: "OnTrapTriggered",
1183                expected_type: GffType::ResRef,
1184                required: false,
1185                children: None,
1186                constraint: None,
1187            },
1188            FieldSchema {
1189                label: "OnUnlock",
1190                expected_type: GffType::ResRef,
1191                required: false,
1192                children: None,
1193                constraint: None,
1194            },
1195            FieldSchema {
1196                label: "OnUsed",
1197                expected_type: GffType::ResRef,
1198                required: false,
1199                children: None,
1200                constraint: None,
1201            },
1202            FieldSchema {
1203                label: "OnUserDefined",
1204                expected_type: GffType::ResRef,
1205                required: false,
1206                children: None,
1207                constraint: None,
1208            },
1209            FieldSchema {
1210                label: "OnDialog",
1211                expected_type: GffType::ResRef,
1212                required: false,
1213                children: None,
1214                constraint: None,
1215            },
1216            FieldSchema {
1217                label: "OnEndDialogue",
1218                expected_type: GffType::ResRef,
1219                required: false,
1220                children: None,
1221                constraint: None,
1222            },
1223            // --- Engine-read list ---
1224            FieldSchema {
1225                label: "ItemList",
1226                expected_type: GffType::List,
1227                required: false,
1228                children: Some(ITEM_LIST_CHILDREN),
1229                constraint: None,
1230            },
1231            // --- Toolset-only fields ---
1232            FieldSchema {
1233                label: "TemplateResRef",
1234                expected_type: GffType::ResRef,
1235                required: false,
1236                children: None,
1237                constraint: None,
1238            },
1239            FieldSchema {
1240                label: "Comment",
1241                expected_type: GffType::String,
1242                required: false,
1243                children: None,
1244                constraint: None,
1245            },
1246            FieldSchema {
1247                label: "PaletteID",
1248                expected_type: GffType::UInt8,
1249                required: false,
1250                children: None,
1251                constraint: None,
1252            },
1253            FieldSchema {
1254                label: "OpenLockDiff",
1255                expected_type: GffType::UInt8,
1256                required: false,
1257                children: None,
1258                constraint: None,
1259            },
1260            FieldSchema {
1261                label: "OpenLockDiffMod",
1262                expected_type: GffType::Int8,
1263                required: false,
1264                children: None,
1265                constraint: None,
1266            },
1267            FieldSchema {
1268                label: "NotBlastable",
1269                expected_type: GffType::UInt8,
1270                required: false,
1271                children: None,
1272                constraint: None,
1273            },
1274            FieldSchema {
1275                label: "Interruptable",
1276                expected_type: GffType::UInt8,
1277                required: false,
1278                children: None,
1279                constraint: None,
1280            },
1281            FieldSchema {
1282                label: "Type",
1283                expected_type: GffType::UInt8,
1284                required: false,
1285                children: None,
1286                constraint: None,
1287            },
1288            FieldSchema {
1289                label: "IsBodyBag",
1290                expected_type: GffType::UInt8,
1291                required: false,
1292                children: None,
1293                constraint: None,
1294            },
1295            FieldSchema {
1296                label: "IsCorpse",
1297                expected_type: GffType::UInt8,
1298                required: false,
1299                children: None,
1300                constraint: None,
1301            },
1302        ];
1303        SCHEMA
1304    }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use super::*;
1310
1311    const TEST_UTP: &[u8] = include_bytes!(concat!(
1312        env!("CARGO_MANIFEST_DIR"),
1313        "/../../fixtures/test.utp"
1314    ));
1315
1316    #[test]
1317    fn reads_core_utp_fields_from_fixture() {
1318        let utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
1319
1320        assert_eq!(utp.tag, "SecLoc");
1321        assert_eq!(utp.template_resref, "lockerlg002");
1322        assert_eq!(utp.name.string_ref.raw(), 74_450);
1323        assert_eq!(utp.description.string_ref.raw(), -1);
1324        assert!(utp.auto_remove_key);
1325        assert_eq!(utp.close_lock_dc, 13);
1326        assert_eq!(utp.conversation, "conversation");
1327        assert_eq!(utp.faction_id, 1);
1328        assert!(utp.plot);
1329        assert!(utp.not_blastable);
1330        assert!(utp.min1_hp);
1331        assert!(utp.key_required);
1332        assert!(!utp.lockable);
1333        assert!(utp.locked);
1334        assert_eq!(utp.open_lock_dc, 28);
1335        assert_eq!(utp.open_lock_diff, 1);
1336        assert_eq!(utp.open_lock_diff_mod, 1);
1337        assert_eq!(utp.key_name, "somekey");
1338        assert_eq!(utp.animation_state, 2);
1339        assert_eq!(utp.appearance_id, 67);
1340        assert_eq!(utp.maximum_hp, 15);
1341        assert_eq!(utp.current_hp, 15);
1342        assert_eq!(utp.hardness, 5);
1343        assert_eq!(utp.fortitude, 16);
1344        assert_eq!(utp.reflex, 0);
1345        assert_eq!(utp.will, 0);
1346        assert_eq!(utp.on_closed, "onclosed");
1347        assert_eq!(utp.on_damaged, "ondamaged");
1348        assert_eq!(utp.on_death, "ondeath");
1349        assert_eq!(utp.on_disarm, "ondisarm");
1350        assert_eq!(utp.on_heartbeat, "onheartbeat");
1351        assert_eq!(utp.on_inventory, "oninvdisturbed");
1352        assert_eq!(utp.on_lock, "onlock");
1353        assert_eq!(utp.on_melee_attacked, "onmeleeattacked");
1354        assert_eq!(utp.on_open, "onopen");
1355        assert_eq!(utp.on_spell_cast_at, "onspellcastat");
1356        assert_eq!(utp.on_unlock, "onunlock");
1357        assert_eq!(utp.on_used, "onused");
1358        assert_eq!(utp.on_user_defined, "onuserdefined");
1359        assert_eq!(utp.on_end_dialogue, "onenddialogue");
1360        assert_eq!(utp.on_fail_to_open, "onfailtoopen");
1361        assert!(utp.has_inventory);
1362        assert!(utp.party_interact);
1363        assert!(utp.is_static);
1364        assert!(utp.useable);
1365        assert_eq!(utp.comment, "Large standup locker");
1366        assert!(utp.interruptable);
1367        assert_eq!(utp.portrait_id, 0);
1368        assert!(utp.trap_detectable);
1369        assert_eq!(utp.trap_detect_dc, 0);
1370        assert!(utp.trap_disarmable);
1371        assert_eq!(utp.trap_disarm_dc, 15);
1372        assert_eq!(utp.trap_flag, 0);
1373        assert!(utp.trap_one_shot);
1374        assert_eq!(utp.trap_type, 0);
1375        assert_eq!(utp.bodybag_id, 0);
1376        assert_eq!(utp.type_id, 0);
1377        assert_eq!(utp.palette_id, 6);
1378
1379        assert_eq!(utp.inventory.len(), 2);
1380        assert_eq!(utp.inventory[0].inventory_res, "g_w_iongren01");
1381        assert!(!utp.inventory[0].droppable);
1382        assert_eq!(utp.inventory[1].inventory_res, "g_w_iongren02");
1383        assert!(utp.inventory[1].droppable);
1384    }
1385
1386    #[test]
1387    fn all_fields_survive_typed_roundtrip() {
1388        let utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
1389        let bytes = write_utp_to_vec(&utp).expect("write succeeds");
1390        let reparsed = read_utp_from_bytes(&bytes).expect("reparse succeeds");
1391        assert_eq!(reparsed, utp);
1392    }
1393
1394    #[test]
1395    fn typed_edits_roundtrip_through_gff_writer() {
1396        let mut utp = read_utp_from_bytes(TEST_UTP).expect("fixture must parse");
1397        utp.tag = "SecLocRust".into();
1398        utp.open_lock_dc = 33;
1399        utp.locked = false;
1400        utp.inventory[0].droppable = true;
1401        utp.comment = "Rust comment".into();
1402        utp.on_open = ResRef::new("k_on_open_new").expect("valid test resref");
1403
1404        let encoded = write_utp_to_vec(&utp).expect("encode");
1405        let reparsed = read_utp_from_bytes(&encoded).expect("decode");
1406
1407        assert_eq!(reparsed.tag, "SecLocRust");
1408        assert_eq!(reparsed.open_lock_dc, 33);
1409        assert!(!reparsed.locked);
1410        assert!(reparsed.inventory[0].droppable);
1411        assert_eq!(reparsed.comment, "Rust comment");
1412        assert_eq!(reparsed.on_open, "k_on_open_new");
1413    }
1414
1415    #[test]
1416    fn static_defaults_to_inverse_of_useable_when_static_missing() {
1417        let mut root = GffStruct::new(-1);
1418        root.push_field("Useable", GffValue::UInt8(1));
1419        root.push_field("ItemList", GffValue::List(Vec::new()));
1420        let utp = Utp::from_gff(&Gff::new(*b"UTP ", root)).expect("must parse");
1421        assert!(!utp.is_static);
1422
1423        let mut root2 = GffStruct::new(-1);
1424        root2.push_field("Useable", GffValue::UInt8(0));
1425        root2.push_field("ItemList", GffValue::List(Vec::new()));
1426        let utp2 = Utp::from_gff(&Gff::new(*b"UTP ", root2)).expect("must parse");
1427        assert!(utp2.is_static);
1428    }
1429
1430    #[test]
1431    fn rejects_non_utp_file_type() {
1432        let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
1433        let err = Utp::from_gff(&gff).expect_err("must fail");
1434        assert!(matches!(err, UtpError::UnsupportedFileType(file_type) if file_type == *b"UTC "));
1435    }
1436
1437    #[test]
1438    fn read_utp_from_reader_matches_bytes_path() {
1439        let mut cursor = Cursor::new(TEST_UTP);
1440        let via_reader = read_utp(&mut cursor).expect("reader parse");
1441        let via_bytes = read_utp_from_bytes(TEST_UTP).expect("bytes parse");
1442        assert_eq!(via_reader.tag, via_bytes.tag);
1443        assert_eq!(via_reader.inventory.len(), via_bytes.inventory.len());
1444    }
1445
1446    #[test]
1447    fn type_mismatch_on_item_list_is_error() {
1448        let mut root = GffStruct::new(-1);
1449        root.push_field("ItemList", GffValue::UInt32(7));
1450        let gff = Gff::new(*b"UTP ", root);
1451        let err = Utp::from_gff(&gff).expect_err("must fail");
1452        assert!(matches!(
1453            err,
1454            UtpError::TypeMismatch {
1455                field: "ItemList",
1456                expected: "List"
1457            }
1458        ));
1459    }
1460
1461    #[test]
1462    fn write_utp_matches_direct_gff_writer() {
1463        let utp = read_utp_from_bytes(TEST_UTP).expect("fixture parse");
1464        let from_utp = write_utp_to_vec(&utp).expect("utp encode");
1465
1466        let gff = utp.to_gff();
1467        let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
1468        assert_eq!(from_utp, from_gff);
1469    }
1470
1471    #[test]
1472    fn schema_field_count() {
1473        assert_eq!(Utp::schema().len(), 69);
1474    }
1475
1476    #[test]
1477    fn schema_no_duplicate_labels() {
1478        let schema = Utp::schema();
1479        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1480        labels.sort();
1481        let before = labels.len();
1482        labels.dedup();
1483        assert_eq!(before, labels.len(), "duplicate labels in UTP schema");
1484    }
1485
1486    #[test]
1487    fn schema_item_list_has_children() {
1488        let item_list = Utp::schema()
1489            .iter()
1490            .find(|f| f.label == "ItemList")
1491            .expect("test fixture must be valid");
1492        assert!(item_list.children.is_some());
1493        assert_eq!(
1494            item_list
1495                .children
1496                .expect("test fixture must be valid")
1497                .len(),
1498            7
1499        );
1500    }
1501}