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