Skip to main content

rakata_generics/
utd.rs

1//! UTD (`.utd`) typed generic wrapper.
2//!
3//! UTD resources are GFF-backed door templates.
4//!
5//! ## Scope of this slice
6//! - Typed access for all door identity/state/script/link fields.
7//! - K1-aligned read defaults for door booleans observed in `LoadDoor`.
8//! - Superset-correct: all fields are modeled, no passthrough needed.
9//!
10//! ## Field Layout (simplified)
11//! ```text
12//! UTD root struct
13//! +-- TemplateResRef / Tag / LocName / Description
14//! +-- GenericType / Appearance / OpenState
15//! +-- Lock + trap + durability fields
16//! +-- Script hooks (OnClosed/OnDamaged/...)
17//! +-- Transition fields (LinkedTo/LinkedToFlags/LinkedToModule/TransitionDestination)
18//! ```
19
20use std::io::{Cursor, Read, Write};
21
22use crate::gff_helpers::{
23    get_bool, get_f32, get_i16, get_i8, get_locstring, get_resref, get_string, get_u16, get_u32,
24    get_u8, upsert_field,
25};
26use crate::shared::{CommonTrapScripts, TrapSettings};
27use rakata_core::{ResRef, StrRef};
28use rakata_formats::{
29    gff_schema::{FieldConstraint, FieldSchema, GffSchema, GffType},
30    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
31    GffValue,
32};
33use thiserror::Error;
34
35/// Typed UTD model built from/to [`Gff`] data.
36#[derive(Debug, Clone, PartialEq)]
37pub struct Utd {
38    /// Door template resref (`TemplateResRef`).
39    pub template_resref: ResRef,
40    /// Door tag (`Tag`).
41    pub tag: String,
42    /// Localized door name (`LocName`).
43    pub name: GffLocalizedString,
44    /// Localized door description (`Description`).
45    pub description: GffLocalizedString,
46    /// Toolset comment (`Comment`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
47    pub comment: String,
48    /// Conversation resref (`Conversation`).
49    pub conversation: ResRef,
50    /// Faction identifier (`Faction`).
51    pub faction_id: u32,
52    /// Generic door type id (`GenericType`).
53    pub appearance_id: u8,
54    /// Optional appearance table index (`Appearance`). Engine truncates to a single byte; values above 255 wrap to 0 and break door model rendering.
55    pub unused_appearance_id: u32,
56    /// Open state (`OpenState`).
57    pub open_state: u8,
58    /// Animation state (`AnimationState`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
59    pub animation_state: u8,
60    /// Auto-remove-key flag (`AutoRemoveKey`).
61    pub auto_remove_key: bool,
62    /// Door bearing in radians (`Bearing`).
63    pub bearing: f32,
64    /// Key name (`KeyName`).
65    pub key_name: String,
66    /// Key-required flag (`KeyRequired`).
67    pub key_required: bool,
68    /// Lockable flag (`Lockable`).
69    pub lockable: bool,
70    /// Locked flag (`Locked`).
71    pub locked: bool,
72    /// Open lock DC (`OpenLockDC`).
73    pub open_lock_dc: u8,
74    /// Close lock DC (`CloseLockDC`).
75    pub close_lock_dc: u8,
76    /// Secret door detect DC (`SecretDoorDC`).
77    pub secret_door_dc: u8,
78    /// Open lock difficulty (`OpenLockDiff`, K2-oriented field).
79    pub open_lock_diff: u8,
80    /// Open lock difficulty modifier (`OpenLockDiffMod`, K2-oriented field).
81    pub open_lock_diff_mod: i8,
82    /// Current hit points (`CurrentHP`). Engine clamps this to `maximum_hp` on template load.
83    pub current_hp: i16,
84    /// Maximum hit points (`HP`).
85    pub maximum_hp: i16,
86    /// Hardness (`Hardness`).
87    pub hardness: u8,
88    /// Fortitude save (`Fort`).
89    pub fortitude: u8,
90    /// Reflex save (`Ref`).
91    pub reflex: u8,
92    /// Will save (`Will`).
93    pub will: u8,
94    /// Plot flag (`Plot`). If `is_static` is true, the engine will force this to true at runtime.
95    pub plot: bool,
96    /// Invulnerable flag (`Invulnerable`).
97    pub invulnerable: bool,
98    /// Min-1HP flag (`Min1HP`).
99    pub min1_hp: bool,
100    /// Static flag (`Static`).
101    pub is_static: bool,
102    /// Not-blastable flag (`NotBlastable`, K2-oriented field).
103    pub not_blastable: bool,
104    /// Interruptable flag (`Interruptable`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
105    pub interruptable: bool,
106    /// Portrait ID (`PortraitId`). If `< 0xFFFE`, the engine completely shadows and ignores the string `Portrait` resref field. If `0`, the engine hardcodes the lookup to portrait id `0x22E`.
107    pub portrait_id: u16,
108    /// Portrait resref (`Portrait`). Ignored by the engine if `portrait_id` is `< 0xFFFE`.
109    pub portrait: ResRef,
110    /// Palette ID (`PaletteID`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
111    pub palette_id: u8,
112    /// Trap-detectable flag (`TrapDetectable`).
113    pub trap_detectable: bool,
114    /// Trap detect DC (`TrapDetectDC`).
115    pub trap_detect_dc: u8,
116    /// Trap-disarmable flag (`TrapDisarmable`).
117    pub trap_disarmable: bool,
118    /// Trap disarm DC (`DisarmDC`).
119    pub trap_disarm_dc: u8,
120    /// Trap flag (`TrapFlag`).
121    pub trap_flag: u8,
122    /// Trap one-shot flag (`TrapOneShot`).
123    pub trap_one_shot: bool,
124    /// Trap type (`TrapType`).
125    pub trap_type: u8,
126    /// On-closed script (`OnClosed`).
127    pub on_closed: ResRef,
128    /// On-damaged script (`OnDamaged`).
129    pub on_damaged: ResRef,
130    /// On-death script (`OnDeath`).
131    pub on_death: ResRef,
132    /// On-disarm script (`OnDisarm`).
133    pub on_disarm: ResRef,
134    /// On-heartbeat script (`OnHeartbeat`).
135    pub on_heartbeat: ResRef,
136    /// On-lock script (`OnLock`).
137    pub on_lock: ResRef,
138    /// On-melee-attacked script (`OnMeleeAttacked`).
139    pub on_melee_attacked: ResRef,
140    /// On-open script (`OnOpen`).
141    pub on_open: ResRef,
142    /// On-spell-cast-at script (`OnSpellCastAt`).
143    pub on_spell_cast_at: ResRef,
144    /// On-trap-triggered script (`OnTrapTriggered`). If empty, null, or literally named `"default"`, the engine pulls the standard script from `traps.2da` keyed by `trap_type`.
145    pub on_trap_triggered: ResRef,
146    /// On-unlock script (`OnUnlock`).
147    pub on_unlock: ResRef,
148    /// On-user-defined script (`OnUserDefined`).
149    pub on_user_defined: ResRef,
150    /// On-click script (`OnClick`).
151    pub on_click: ResRef,
152    /// On-open-failed script (`OnFailToOpen`).
153    pub on_fail_to_open: ResRef,
154    /// On-dialog script (`OnDialog`, K2-oriented field).
155    pub on_dialog: ResRef,
156    /// Linked target flags (`LinkedToFlags`).
157    pub linked_to_flags: u8,
158    /// Linked target tag (`LinkedTo`).
159    pub linked_to: String,
160    /// Linked target module (`LinkedToModule`).
161    pub linked_to_module: ResRef,
162    /// Localized transition destination (`TransitionDestination`).
163    pub transition_destination: GffLocalizedString,
164    /// Load screen id (`LoadScreenID`).
165    pub loadscreen_id: u16,
166}
167
168impl Default for Utd {
169    fn default() -> Self {
170        Self {
171            template_resref: ResRef::blank(),
172            tag: String::new(),
173            name: GffLocalizedString::new(StrRef::invalid()),
174            description: GffLocalizedString::new(StrRef::invalid()),
175            comment: String::new(),
176            conversation: ResRef::blank(),
177            faction_id: 0,
178            appearance_id: 0,
179            unused_appearance_id: 0,
180            open_state: 0,
181            animation_state: 0,
182            auto_remove_key: false,
183            bearing: 0.0,
184            key_name: String::new(),
185            key_required: false,
186            lockable: false,
187            locked: false,
188            open_lock_dc: 0,
189            close_lock_dc: 0,
190            secret_door_dc: 0,
191            open_lock_diff: 0,
192            open_lock_diff_mod: 0,
193            current_hp: 0,
194            maximum_hp: 0,
195            hardness: 0,
196            fortitude: 0,
197            reflex: 0,
198            will: 0,
199            plot: false,
200            invulnerable: false,
201            min1_hp: false,
202            is_static: false,
203            not_blastable: false,
204            interruptable: false,
205            portrait_id: 0,
206            portrait: ResRef::blank(),
207            palette_id: 0,
208            trap_detectable: false,
209            trap_detect_dc: 0,
210            trap_disarmable: false,
211            trap_disarm_dc: 0,
212            trap_flag: 0,
213            trap_one_shot: false,
214            trap_type: 0,
215            on_closed: ResRef::blank(),
216            on_damaged: ResRef::blank(),
217            on_death: ResRef::blank(),
218            on_disarm: ResRef::blank(),
219            on_heartbeat: ResRef::blank(),
220            on_lock: ResRef::blank(),
221            on_melee_attacked: ResRef::blank(),
222            on_open: ResRef::blank(),
223            on_spell_cast_at: ResRef::blank(),
224            on_trap_triggered: ResRef::blank(),
225            on_unlock: ResRef::blank(),
226            on_user_defined: ResRef::blank(),
227            on_click: ResRef::blank(),
228            on_fail_to_open: ResRef::blank(),
229            on_dialog: ResRef::blank(),
230            linked_to_flags: 0,
231            linked_to: String::new(),
232            linked_to_module: ResRef::blank(),
233            transition_destination: GffLocalizedString::new(StrRef::invalid()),
234            loadscreen_id: 0,
235        }
236    }
237}
238
239impl Utd {
240    /// Creates an empty UTD value.
241    pub fn new() -> Self {
242        Self::default()
243    }
244
245    /// Returns trap-related settings as a shared typed block.
246    pub fn trap_settings(&self) -> TrapSettings {
247        TrapSettings {
248            detectable: self.trap_detectable,
249            detect_dc: self.trap_detect_dc,
250            disarmable: self.trap_disarmable,
251            disarm_dc: self.trap_disarm_dc,
252            flag: self.trap_flag,
253            one_shot: self.trap_one_shot,
254            trap_type: self.trap_type,
255        }
256    }
257
258    /// Applies trap-related settings from a shared typed block.
259    pub fn set_trap_settings(&mut self, trap: TrapSettings) {
260        self.trap_detectable = trap.detectable;
261        self.trap_detect_dc = trap.detect_dc;
262        self.trap_disarmable = trap.disarmable;
263        self.trap_disarm_dc = trap.disarm_dc;
264        self.trap_flag = trap.flag;
265        self.trap_one_shot = trap.one_shot;
266        self.trap_type = trap.trap_type;
267    }
268
269    /// Returns the common trap-script hooks as a shared typed bundle.
270    pub fn common_trap_scripts(&self) -> CommonTrapScripts {
271        CommonTrapScripts {
272            on_closed: self.on_closed,
273            on_damaged: self.on_damaged,
274            on_death: self.on_death,
275            on_disarm: self.on_disarm,
276            on_heartbeat: self.on_heartbeat,
277            on_lock: self.on_lock,
278            on_melee_attacked: self.on_melee_attacked,
279            on_open: self.on_open,
280            on_spell_cast_at: self.on_spell_cast_at,
281            on_trap_triggered: self.on_trap_triggered,
282            on_unlock: self.on_unlock,
283            on_user_defined: self.on_user_defined,
284            on_fail_to_open: self.on_fail_to_open,
285        }
286    }
287
288    /// Applies the common trap-script hooks from a shared typed bundle.
289    pub fn set_common_trap_scripts(&mut self, scripts: CommonTrapScripts) {
290        self.on_closed = scripts.on_closed;
291        self.on_damaged = scripts.on_damaged;
292        self.on_death = scripts.on_death;
293        self.on_disarm = scripts.on_disarm;
294        self.on_heartbeat = scripts.on_heartbeat;
295        self.on_lock = scripts.on_lock;
296        self.on_melee_attacked = scripts.on_melee_attacked;
297        self.on_open = scripts.on_open;
298        self.on_spell_cast_at = scripts.on_spell_cast_at;
299        self.on_trap_triggered = scripts.on_trap_triggered;
300        self.on_unlock = scripts.on_unlock;
301        self.on_user_defined = scripts.on_user_defined;
302        self.on_fail_to_open = scripts.on_fail_to_open;
303    }
304
305    /// Builds typed UTD data from a parsed GFF container.
306    pub fn from_gff(gff: &Gff) -> Result<Self, UtdError> {
307        if gff.file_type != *b"UTD " && gff.file_type != *b"GFF " {
308            return Err(UtdError::UnsupportedFileType(gff.file_type));
309        }
310
311        let root = &gff.root;
312
313        if matches!(root.field("TransitionDestination"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
314        {
315            return Err(UtdError::TypeMismatch {
316                field: "TransitionDestination",
317                expected: "LocalizedString",
318            });
319        }
320        if matches!(root.field("TransDest"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
321        {
322            return Err(UtdError::TypeMismatch {
323                field: "TransDest",
324                expected: "LocalizedString",
325            });
326        }
327
328        let plot = get_bool(root, "Plot").unwrap_or(false);
329        // K1 loader reads `Invulnerable` first with a plot-byte fallback path.
330        let invulnerable = get_bool(root, "Invulnerable").unwrap_or(plot);
331
332        let trap = TrapSettings::read(|label| get_bool(root, label), |label| get_u8(root, label));
333        let common_scripts = CommonTrapScripts::read(|label| get_resref(root, label));
334
335        Ok(Self {
336            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
337            tag: get_string(root, "Tag").unwrap_or_default(),
338            name: get_locstring(root, "LocName")
339                .cloned()
340                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
341            description: get_locstring(root, "Description")
342                .cloned()
343                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
344            comment: get_string(root, "Comment").unwrap_or_default(),
345            conversation: get_resref(root, "Conversation").unwrap_or_default(),
346            faction_id: get_u32(root, "Faction").unwrap_or(0),
347            appearance_id: get_u8(root, "GenericType").unwrap_or(0),
348            unused_appearance_id: get_u32(root, "Appearance").unwrap_or(0),
349            open_state: get_u8(root, "OpenState").unwrap_or(0),
350            animation_state: get_u8(root, "AnimationState").unwrap_or(0),
351            auto_remove_key: get_bool(root, "AutoRemoveKey").unwrap_or(false),
352            bearing: get_f32(root, "Bearing").unwrap_or(0.0),
353            key_name: get_string(root, "KeyName").unwrap_or_default(),
354            key_required: get_bool(root, "KeyRequired").unwrap_or(false),
355            lockable: get_bool(root, "Lockable").unwrap_or(false),
356            locked: get_bool(root, "Locked").unwrap_or(false),
357            open_lock_dc: get_u8(root, "OpenLockDC").unwrap_or(0),
358            close_lock_dc: get_u8(root, "CloseLockDC").unwrap_or(0),
359            secret_door_dc: get_u8(root, "SecretDoorDC").unwrap_or(0),
360            open_lock_diff: get_u8(root, "OpenLockDiff").unwrap_or(0),
361            open_lock_diff_mod: get_i8(root, "OpenLockDiffMod").unwrap_or(0),
362            current_hp: get_i16(root, "CurrentHP").unwrap_or(0),
363            maximum_hp: get_i16(root, "HP").unwrap_or(0),
364            hardness: get_u8(root, "Hardness").unwrap_or(0),
365            fortitude: get_u8(root, "Fort").unwrap_or(0),
366            reflex: get_u8(root, "Ref").unwrap_or(0),
367            will: get_u8(root, "Will").unwrap_or(0),
368            plot,
369            invulnerable,
370            min1_hp: get_bool(root, "Min1HP").unwrap_or(false),
371            is_static: get_bool(root, "Static").unwrap_or(false),
372            not_blastable: get_bool(root, "NotBlastable").unwrap_or(false),
373            interruptable: get_bool(root, "Interruptable").unwrap_or(false),
374            portrait_id: get_u16(root, "PortraitId").unwrap_or(0),
375            portrait: get_resref(root, "Portrait").unwrap_or_default(),
376            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
377            trap_detectable: trap.detectable,
378            trap_detect_dc: trap.detect_dc,
379            trap_disarmable: trap.disarmable,
380            trap_disarm_dc: trap.disarm_dc,
381            trap_flag: trap.flag,
382            trap_one_shot: trap.one_shot,
383            trap_type: trap.trap_type,
384            on_closed: common_scripts.on_closed,
385            on_damaged: common_scripts.on_damaged,
386            on_death: common_scripts.on_death,
387            on_disarm: common_scripts.on_disarm,
388            on_heartbeat: common_scripts.on_heartbeat,
389            on_lock: common_scripts.on_lock,
390            on_melee_attacked: common_scripts.on_melee_attacked,
391            on_open: common_scripts.on_open,
392            on_spell_cast_at: common_scripts.on_spell_cast_at,
393            on_trap_triggered: common_scripts.on_trap_triggered,
394            on_unlock: common_scripts.on_unlock,
395            on_user_defined: common_scripts.on_user_defined,
396            on_click: get_resref(root, "OnClick").unwrap_or_default(),
397            on_fail_to_open: common_scripts.on_fail_to_open,
398            on_dialog: get_resref(root, "OnDialog").unwrap_or_default(),
399            linked_to_flags: get_u8(root, "LinkedToFlags").unwrap_or(0),
400            linked_to: get_string(root, "LinkedTo").unwrap_or_default(),
401            linked_to_module: get_resref(root, "LinkedToModule").unwrap_or_default(),
402            transition_destination: get_locstring(root, "TransitionDestination")
403                .or_else(|| get_locstring(root, "TransDest"))
404                .cloned()
405                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
406            loadscreen_id: get_u16(root, "LoadScreenID").unwrap_or(0),
407        })
408    }
409
410    /// Converts this typed UTD value into a GFF container.
411    pub fn to_gff(&self) -> Gff {
412        let mut root = GffStruct::new(-1);
413
414        upsert_field(
415            &mut root,
416            "TemplateResRef",
417            GffValue::ResRef(self.template_resref),
418        );
419        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
420        upsert_field(
421            &mut root,
422            "LocName",
423            GffValue::LocalizedString(self.name.clone()),
424        );
425        upsert_field(
426            &mut root,
427            "Description",
428            GffValue::LocalizedString(self.description.clone()),
429        );
430        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
431        upsert_field(
432            &mut root,
433            "Conversation",
434            GffValue::ResRef(self.conversation),
435        );
436
437        upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
438        upsert_field(
439            &mut root,
440            "GenericType",
441            GffValue::UInt8(self.appearance_id),
442        );
443        upsert_field(
444            &mut root,
445            "Appearance",
446            GffValue::UInt32(self.unused_appearance_id),
447        );
448        upsert_field(&mut root, "OpenState", GffValue::UInt8(self.open_state));
449        upsert_field(
450            &mut root,
451            "AnimationState",
452            GffValue::UInt8(self.animation_state),
453        );
454        upsert_field(
455            &mut root,
456            "AutoRemoveKey",
457            GffValue::UInt8(u8::from(self.auto_remove_key)),
458        );
459        upsert_field(&mut root, "Bearing", GffValue::Single(self.bearing));
460
461        upsert_field(
462            &mut root,
463            "KeyName",
464            GffValue::String(self.key_name.clone()),
465        );
466        upsert_field(
467            &mut root,
468            "KeyRequired",
469            GffValue::UInt8(u8::from(self.key_required)),
470        );
471        upsert_field(
472            &mut root,
473            "Lockable",
474            GffValue::UInt8(u8::from(self.lockable)),
475        );
476        upsert_field(&mut root, "Locked", GffValue::UInt8(u8::from(self.locked)));
477        upsert_field(&mut root, "OpenLockDC", GffValue::UInt8(self.open_lock_dc));
478        upsert_field(
479            &mut root,
480            "CloseLockDC",
481            GffValue::UInt8(self.close_lock_dc),
482        );
483        upsert_field(
484            &mut root,
485            "SecretDoorDC",
486            GffValue::UInt8(self.secret_door_dc),
487        );
488        upsert_field(
489            &mut root,
490            "OpenLockDiff",
491            GffValue::UInt8(self.open_lock_diff),
492        );
493        upsert_field(
494            &mut root,
495            "OpenLockDiffMod",
496            GffValue::Int8(self.open_lock_diff_mod),
497        );
498
499        upsert_field(&mut root, "CurrentHP", GffValue::Int16(self.current_hp));
500        upsert_field(&mut root, "HP", GffValue::Int16(self.maximum_hp));
501        upsert_field(&mut root, "Hardness", GffValue::UInt8(self.hardness));
502        upsert_field(&mut root, "Fort", GffValue::UInt8(self.fortitude));
503        upsert_field(&mut root, "Ref", GffValue::UInt8(self.reflex));
504        upsert_field(&mut root, "Will", GffValue::UInt8(self.will));
505
506        upsert_field(&mut root, "Plot", GffValue::UInt8(u8::from(self.plot)));
507        upsert_field(
508            &mut root,
509            "Invulnerable",
510            GffValue::UInt8(u8::from(self.invulnerable)),
511        );
512        upsert_field(&mut root, "Min1HP", GffValue::UInt8(u8::from(self.min1_hp)));
513        upsert_field(
514            &mut root,
515            "Static",
516            GffValue::UInt8(u8::from(self.is_static)),
517        );
518        upsert_field(
519            &mut root,
520            "NotBlastable",
521            GffValue::UInt8(u8::from(self.not_blastable)),
522        );
523        upsert_field(
524            &mut root,
525            "Interruptable",
526            GffValue::UInt8(u8::from(self.interruptable)),
527        );
528        upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
529        upsert_field(&mut root, "Portrait", GffValue::ResRef(self.portrait));
530        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
531
532        self.trap_settings()
533            .write(|label, value| upsert_field(&mut root, label, value));
534        self.common_trap_scripts()
535            .write(|label, value| upsert_field(&mut root, label, value));
536        upsert_field(&mut root, "OnClick", GffValue::ResRef(self.on_click));
537        upsert_field(
538            &mut root,
539            "OnFailToOpen",
540            GffValue::ResRef(self.on_fail_to_open),
541        );
542        upsert_field(&mut root, "OnDialog", GffValue::ResRef(self.on_dialog));
543
544        upsert_field(
545            &mut root,
546            "LinkedToFlags",
547            GffValue::UInt8(self.linked_to_flags),
548        );
549        upsert_field(
550            &mut root,
551            "LinkedTo",
552            GffValue::String(self.linked_to.clone()),
553        );
554        upsert_field(
555            &mut root,
556            "LinkedToModule",
557            GffValue::ResRef(self.linked_to_module),
558        );
559        // GFF binary labels are limited to 16 bytes, so we only update
560        // transition-destination labels when a source template already carries
561        // one (`TransitionDestination` in non-binary sources or `TransDest`).
562        if root.field("TransitionDestination").is_some() {
563            upsert_field(
564                &mut root,
565                "TransitionDestination",
566                GffValue::LocalizedString(self.transition_destination.clone()),
567            );
568        } else if root.field("TransDest").is_some() {
569            upsert_field(
570                &mut root,
571                "TransDest",
572                GffValue::LocalizedString(self.transition_destination.clone()),
573            );
574        }
575        upsert_field(
576            &mut root,
577            "LoadScreenID",
578            GffValue::UInt16(self.loadscreen_id),
579        );
580
581        Gff::new(*b"UTD ", root)
582    }
583}
584
585/// Errors produced while reading or writing typed UTD data.
586#[derive(Debug, Error)]
587pub enum UtdError {
588    /// Source file type is not supported by this parser.
589    #[error("unsupported UTD file type: {0:?}")]
590    UnsupportedFileType([u8; 4]),
591    /// A required container field had an unexpected runtime type.
592    #[error("UTD field `{field}` has incompatible type (expected {expected})")]
593    TypeMismatch {
594        /// Field label where mismatch occurred.
595        field: &'static str,
596        /// Expected runtime value kind.
597        expected: &'static str,
598    },
599    /// Underlying GFF parser/writer error.
600    #[error(transparent)]
601    Gff(#[from] GffBinaryError),
602}
603
604/// Reads typed UTD data from a reader at the current stream position.
605#[cfg_attr(
606    feature = "tracing",
607    tracing::instrument(level = "debug", skip(reader))
608)]
609pub fn read_utd<R: Read>(reader: &mut R) -> Result<Utd, UtdError> {
610    let gff = read_gff(reader)?;
611    Utd::from_gff(&gff)
612}
613
614/// Reads typed UTD data directly from bytes.
615#[cfg_attr(
616    feature = "tracing",
617    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
618)]
619pub fn read_utd_from_bytes(bytes: &[u8]) -> Result<Utd, UtdError> {
620    let gff = read_gff_from_bytes(bytes)?;
621    Utd::from_gff(&gff)
622}
623
624/// Writes typed UTD data to an output writer.
625#[cfg_attr(
626    feature = "tracing",
627    tracing::instrument(level = "debug", skip(writer, utd))
628)]
629pub fn write_utd<W: Write>(writer: &mut W, utd: &Utd) -> Result<(), UtdError> {
630    let gff = utd.to_gff();
631    write_gff(writer, &gff)?;
632    Ok(())
633}
634
635/// Serializes typed UTD data into a byte vector.
636#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utd)))]
637pub fn write_utd_to_vec(utd: &Utd) -> Result<Vec<u8>, UtdError> {
638    let mut cursor = Cursor::new(Vec::new());
639    write_utd(&mut cursor, utd)?;
640    Ok(cursor.into_inner())
641}
642
643impl GffSchema for Utd {
644    fn schema() -> &'static [FieldSchema] {
645        static SCHEMA: &[FieldSchema] = &[
646            // --- Identity ---
647            FieldSchema {
648                label: "Tag",
649                expected_type: GffType::String,
650                required: false,
651                children: None,
652                constraint: None,
653            },
654            FieldSchema {
655                label: "LocName",
656                expected_type: GffType::LocalizedString,
657                required: false,
658                children: None,
659                constraint: None,
660            },
661            FieldSchema {
662                label: "Description",
663                expected_type: GffType::LocalizedString,
664                required: false,
665                children: None,
666                constraint: None,
667            },
668            FieldSchema {
669                label: "Conversation",
670                expected_type: GffType::ResRef,
671                required: false,
672                children: None,
673                constraint: None,
674            },
675            FieldSchema {
676                label: "Faction",
677                expected_type: GffType::UInt32,
678                required: false,
679                children: None,
680                constraint: None,
681            },
682            // --- Appearance ---
683            FieldSchema {
684                label: "Appearance",
685                expected_type: GffType::UInt32,
686                required: false,
687                children: None,
688                constraint: Some(FieldConstraint::RangeInt(0, 255)),
689            },
690            FieldSchema {
691                label: "GenericType",
692                expected_type: GffType::UInt8,
693                required: false,
694                children: None,
695                constraint: None,
696            },
697            FieldSchema {
698                label: "OpenState",
699                expected_type: GffType::UInt8,
700                required: false,
701                children: None,
702                constraint: None,
703            },
704            // --- Combat / durability ---
705            FieldSchema {
706                label: "HP",
707                expected_type: GffType::Int16,
708                required: false,
709                children: None,
710                constraint: None,
711            },
712            FieldSchema {
713                label: "CurrentHP",
714                expected_type: GffType::Int16,
715                required: false,
716                children: None,
717                constraint: None,
718            },
719            FieldSchema {
720                label: "Hardness",
721                expected_type: GffType::UInt8,
722                required: false,
723                children: None,
724                constraint: None,
725            },
726            FieldSchema {
727                label: "Fort",
728                expected_type: GffType::UInt8,
729                required: false,
730                children: None,
731                constraint: None,
732            },
733            FieldSchema {
734                label: "Ref",
735                expected_type: GffType::UInt8,
736                required: false,
737                children: None,
738                constraint: None,
739            },
740            FieldSchema {
741                label: "Will",
742                expected_type: GffType::UInt8,
743                required: false,
744                children: None,
745                constraint: None,
746            },
747            // --- Flags ---
748            FieldSchema {
749                label: "Plot",
750                expected_type: GffType::UInt8,
751                required: false,
752                children: None,
753                constraint: None,
754            },
755            FieldSchema {
756                label: "Static",
757                expected_type: GffType::UInt8,
758                required: false,
759                children: None,
760                constraint: None,
761            },
762            FieldSchema {
763                label: "Invulnerable",
764                expected_type: GffType::UInt8,
765                required: false,
766                children: None,
767                constraint: None,
768            },
769            FieldSchema {
770                label: "Min1HP",
771                expected_type: GffType::UInt8,
772                required: false,
773                children: None,
774                constraint: None,
775            },
776            // --- Lock / key ---
777            FieldSchema {
778                label: "Locked",
779                expected_type: GffType::UInt8,
780                required: false,
781                children: None,
782                constraint: None,
783            },
784            FieldSchema {
785                label: "Lockable",
786                expected_type: GffType::UInt8,
787                required: false,
788                children: None,
789                constraint: None,
790            },
791            FieldSchema {
792                label: "OpenLockDC",
793                expected_type: GffType::UInt8,
794                required: false,
795                children: None,
796                constraint: None,
797            },
798            FieldSchema {
799                label: "CloseLockDC",
800                expected_type: GffType::UInt8,
801                required: false,
802                children: None,
803                constraint: None,
804            },
805            FieldSchema {
806                label: "SecretDoorDC",
807                expected_type: GffType::UInt8,
808                required: false,
809                children: None,
810                constraint: None,
811            },
812            FieldSchema {
813                label: "KeyName",
814                expected_type: GffType::String,
815                required: false,
816                children: None,
817                constraint: None,
818            },
819            FieldSchema {
820                label: "KeyRequired",
821                expected_type: GffType::UInt8,
822                required: false,
823                children: None,
824                constraint: None,
825            },
826            FieldSchema {
827                label: "AutoRemoveKey",
828                expected_type: GffType::UInt8,
829                required: false,
830                children: None,
831                constraint: None,
832            },
833            // --- Bearing ---
834            FieldSchema {
835                label: "Bearing",
836                expected_type: GffType::Single,
837                required: false,
838                children: None,
839                constraint: None,
840            },
841            // --- Portrait ---
842            FieldSchema {
843                label: "PortraitId",
844                expected_type: GffType::UInt16,
845                required: false,
846                children: None,
847                constraint: None,
848            },
849            FieldSchema {
850                label: "Portrait",
851                expected_type: GffType::ResRef,
852                required: false,
853                children: None,
854                constraint: None,
855            },
856            // --- Transition ---
857            FieldSchema {
858                label: "LinkedToFlags",
859                expected_type: GffType::UInt8,
860                required: false,
861                children: None,
862                constraint: None,
863            },
864            FieldSchema {
865                label: "LinkedTo",
866                expected_type: GffType::String,
867                required: false,
868                children: None,
869                constraint: None,
870            },
871            FieldSchema {
872                label: "LinkedToModule",
873                expected_type: GffType::ResRef,
874                required: false,
875                children: None,
876                constraint: None,
877            },
878            FieldSchema {
879                label: "TransitionDestination",
880                expected_type: GffType::LocalizedString,
881                required: false,
882                children: None,
883                constraint: None,
884            },
885            FieldSchema {
886                label: "LoadScreenID",
887                expected_type: GffType::UInt16,
888                required: false,
889                children: None,
890                constraint: None,
891            },
892            // --- Trap fields (7) ---
893            FieldSchema {
894                label: "TrapType",
895                expected_type: GffType::UInt8,
896                required: false,
897                children: None,
898                constraint: None,
899            },
900            FieldSchema {
901                label: "TrapDisarmable",
902                expected_type: GffType::UInt8,
903                required: false,
904                children: None,
905                constraint: None,
906            },
907            FieldSchema {
908                label: "TrapDetectable",
909                expected_type: GffType::UInt8,
910                required: false,
911                children: None,
912                constraint: None,
913            },
914            FieldSchema {
915                label: "DisarmDC",
916                expected_type: GffType::UInt8,
917                required: false,
918                children: None,
919                constraint: None,
920            },
921            FieldSchema {
922                label: "TrapDetectDC",
923                expected_type: GffType::UInt8,
924                required: false,
925                children: None,
926                constraint: None,
927            },
928            FieldSchema {
929                label: "TrapFlag",
930                expected_type: GffType::UInt8,
931                required: false,
932                children: None,
933                constraint: None,
934            },
935            FieldSchema {
936                label: "TrapOneShot",
937                expected_type: GffType::UInt8,
938                required: false,
939                children: None,
940                constraint: None,
941            },
942            // --- Scripts (15) ---
943            FieldSchema {
944                label: "OnClosed",
945                expected_type: GffType::ResRef,
946                required: false,
947                children: None,
948                constraint: None,
949            },
950            FieldSchema {
951                label: "OnDamaged",
952                expected_type: GffType::ResRef,
953                required: false,
954                children: None,
955                constraint: None,
956            },
957            FieldSchema {
958                label: "OnDeath",
959                expected_type: GffType::ResRef,
960                required: false,
961                children: None,
962                constraint: None,
963            },
964            FieldSchema {
965                label: "OnDisarm",
966                expected_type: GffType::ResRef,
967                required: false,
968                children: None,
969                constraint: None,
970            },
971            FieldSchema {
972                label: "OnHeartbeat",
973                expected_type: GffType::ResRef,
974                required: false,
975                children: None,
976                constraint: None,
977            },
978            FieldSchema {
979                label: "OnLock",
980                expected_type: GffType::ResRef,
981                required: false,
982                children: None,
983                constraint: None,
984            },
985            FieldSchema {
986                label: "OnMeleeAttacked",
987                expected_type: GffType::ResRef,
988                required: false,
989                children: None,
990                constraint: None,
991            },
992            FieldSchema {
993                label: "OnOpen",
994                expected_type: GffType::ResRef,
995                required: false,
996                children: None,
997                constraint: None,
998            },
999            FieldSchema {
1000                label: "OnSpellCastAt",
1001                expected_type: GffType::ResRef,
1002                required: false,
1003                children: None,
1004                constraint: None,
1005            },
1006            FieldSchema {
1007                label: "OnTrapTriggered",
1008                expected_type: GffType::ResRef,
1009                required: false,
1010                children: None,
1011                constraint: None,
1012            },
1013            FieldSchema {
1014                label: "OnUnlock",
1015                expected_type: GffType::ResRef,
1016                required: false,
1017                children: None,
1018                constraint: None,
1019            },
1020            FieldSchema {
1021                label: "OnUserDefined",
1022                expected_type: GffType::ResRef,
1023                required: false,
1024                children: None,
1025                constraint: None,
1026            },
1027            FieldSchema {
1028                label: "OnClick",
1029                expected_type: GffType::ResRef,
1030                required: false,
1031                children: None,
1032                constraint: None,
1033            },
1034            FieldSchema {
1035                label: "OnFailToOpen",
1036                expected_type: GffType::ResRef,
1037                required: false,
1038                children: None,
1039                constraint: None,
1040            },
1041            FieldSchema {
1042                label: "OnDialog",
1043                expected_type: GffType::ResRef,
1044                required: false,
1045                children: None,
1046                constraint: None,
1047            },
1048            // --- Toolset-only fields ---
1049            FieldSchema {
1050                label: "TemplateResRef",
1051                expected_type: GffType::ResRef,
1052                required: false,
1053                children: None,
1054                constraint: None,
1055            },
1056            FieldSchema {
1057                label: "Comment",
1058                expected_type: GffType::String,
1059                required: false,
1060                children: None,
1061                constraint: None,
1062            },
1063            FieldSchema {
1064                label: "PaletteID",
1065                expected_type: GffType::UInt8,
1066                required: false,
1067                children: None,
1068                constraint: None,
1069            },
1070            FieldSchema {
1071                label: "AnimationState",
1072                expected_type: GffType::UInt8,
1073                required: false,
1074                children: None,
1075                constraint: None,
1076            },
1077            FieldSchema {
1078                label: "OpenLockDiff",
1079                expected_type: GffType::UInt8,
1080                required: false,
1081                children: None,
1082                constraint: None,
1083            },
1084            FieldSchema {
1085                label: "OpenLockDiffMod",
1086                expected_type: GffType::Int8,
1087                required: false,
1088                children: None,
1089                constraint: None,
1090            },
1091            FieldSchema {
1092                label: "NotBlastable",
1093                expected_type: GffType::UInt8,
1094                required: false,
1095                children: None,
1096                constraint: None,
1097            },
1098            FieldSchema {
1099                label: "Interruptable",
1100                expected_type: GffType::UInt8,
1101                required: false,
1102                children: None,
1103                constraint: None,
1104            },
1105        ];
1106        SCHEMA
1107    }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    const TEST_UTD: &[u8] = include_bytes!(concat!(
1115        env!("CARGO_MANIFEST_DIR"),
1116        "/../../fixtures/test.utd"
1117    ));
1118
1119    #[test]
1120    fn reads_core_utd_fields_from_fixture() {
1121        let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1122
1123        assert_eq!(utd.tag, "TelosDoor13");
1124        assert_eq!(utd.template_resref, "door_tel014");
1125        assert_eq!(utd.name.string_ref.raw(), 123_731);
1126        assert_eq!(utd.description.string_ref.raw(), -1);
1127        assert!(utd.auto_remove_key);
1128        assert_eq!(utd.close_lock_dc, 0);
1129        assert_eq!(utd.conversation, "convoresref");
1130        assert!(utd.interruptable);
1131        assert_eq!(utd.faction_id, 1);
1132        assert!(utd.plot);
1133        assert!(utd.not_blastable);
1134        assert!(utd.min1_hp);
1135        assert!(utd.key_required);
1136        assert!(utd.lockable);
1137        assert!(utd.locked);
1138        assert_eq!(utd.open_lock_dc, 28);
1139        assert_eq!(utd.open_lock_diff, 1);
1140        assert_eq!(utd.open_lock_diff_mod, 1);
1141        assert_eq!(utd.portrait_id, 0);
1142        assert!(utd.trap_detectable);
1143        assert_eq!(utd.trap_detect_dc, 0);
1144        assert!(utd.trap_disarmable);
1145        assert_eq!(utd.trap_disarm_dc, 28);
1146        assert_eq!(utd.trap_flag, 0);
1147        assert!(utd.trap_one_shot);
1148        assert_eq!(utd.trap_type, 2);
1149        assert_eq!(utd.key_name, "keyname");
1150        assert_eq!(utd.animation_state, 1);
1151        assert_eq!(utd.unused_appearance_id, 1);
1152        assert_eq!(utd.maximum_hp, 20);
1153        assert_eq!(utd.current_hp, 60);
1154        assert_eq!(utd.hardness, 5);
1155        assert_eq!(utd.fortitude, 28);
1156        assert_eq!(utd.reflex, 0);
1157        assert_eq!(utd.will, 0);
1158        assert_eq!(utd.on_closed, "onclosed");
1159        assert_eq!(utd.on_damaged, "ondamaged");
1160        assert_eq!(utd.on_death, "ondeath");
1161        assert_eq!(utd.on_disarm, "ondisarm");
1162        assert_eq!(utd.on_heartbeat, "onheartbeat");
1163        assert_eq!(utd.on_lock, "onlock");
1164        assert_eq!(utd.on_melee_attacked, "onmeleeattacked");
1165        assert_eq!(utd.on_open, "onopen");
1166        assert_eq!(utd.on_spell_cast_at, "onspellcastat");
1167        assert_eq!(utd.on_trap_triggered, "ontraptriggered");
1168        assert_eq!(utd.on_unlock, "onunlock");
1169        assert_eq!(utd.on_user_defined, "onuserdefined");
1170        assert_eq!(utd.loadscreen_id, 0);
1171        assert_eq!(utd.appearance_id, 110);
1172        assert!(utd.is_static);
1173        assert_eq!(utd.open_state, 1);
1174        assert_eq!(utd.on_click, "onclick");
1175        assert_eq!(utd.on_fail_to_open, "onfailtoopen");
1176        assert_eq!(utd.comment, "abcdefg");
1177        assert_eq!(utd.palette_id, 1);
1178    }
1179
1180    #[test]
1181    fn all_fields_survive_typed_roundtrip() {
1182        let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1183        let bytes = write_utd_to_vec(&utd).expect("write succeeds");
1184        let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
1185        assert_eq!(reparsed, utd);
1186    }
1187
1188    #[test]
1189    fn typed_edits_roundtrip_through_gff_writer() {
1190        let mut utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1191        utd.tag = "TelosDoor13_Rust".into();
1192        utd.open_state = 2;
1193        utd.on_open = ResRef::new("rust_on_open").expect("valid resref literal");
1194
1195        let bytes = write_utd_to_vec(&utd).expect("write succeeds");
1196        let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
1197
1198        assert_eq!(reparsed.tag, "TelosDoor13_Rust");
1199        assert_eq!(reparsed.open_state, 2);
1200        assert_eq!(reparsed.on_open, "rust_on_open");
1201    }
1202
1203    #[test]
1204    fn read_utd_from_reader_matches_bytes_path() {
1205        let mut cursor = Cursor::new(TEST_UTD);
1206        let from_reader = read_utd(&mut cursor).expect("reader parse succeeds");
1207        let from_bytes = read_utd_from_bytes(TEST_UTD).expect("bytes parse succeeds");
1208
1209        assert_eq!(from_reader, from_bytes);
1210    }
1211
1212    #[test]
1213    fn rejects_non_utd_file_type() {
1214        let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
1215        gff.file_type = *b"UTC ";
1216
1217        let err = Utd::from_gff(&gff).expect_err("UTC must be rejected as UTD input");
1218        assert!(matches!(
1219            err,
1220            UtdError::UnsupportedFileType(file_type) if file_type == *b"UTC "
1221        ));
1222    }
1223
1224    #[test]
1225    fn type_mismatch_on_transition_destination_is_error() {
1226        let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
1227        gff.root.push_field("TransDest", GffValue::UInt32(99));
1228
1229        let err = Utd::from_gff(&gff).expect_err("type mismatch must be rejected");
1230        assert!(matches!(
1231            err,
1232            UtdError::TypeMismatch {
1233                field: "TransDest",
1234                expected: "LocalizedString",
1235            }
1236        ));
1237    }
1238
1239    #[test]
1240    fn write_utd_matches_direct_gff_writer() {
1241        let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1242
1243        let via_typed = write_utd_to_vec(&utd).expect("typed write succeeds");
1244
1245        let mut direct = Cursor::new(Vec::new());
1246        write_gff(&mut direct, &utd.to_gff()).expect("direct write succeeds");
1247
1248        assert_eq!(via_typed, direct.into_inner());
1249    }
1250
1251    #[test]
1252    fn schema_field_count() {
1253        assert_eq!(Utd::schema().len(), 64); // 34 core + 7 trap + 15 scripts + 8 toolset
1254    }
1255
1256    #[test]
1257    fn schema_no_duplicate_labels() {
1258        let schema = Utd::schema();
1259        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1260        labels.sort();
1261        let before = labels.len();
1262        labels.dedup();
1263        assert_eq!(before, labels.len(), "duplicate labels in UTD schema");
1264    }
1265}