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