Skip to main content

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