Skip to main content

rakata_generics/
utd.rs

1//! UTD (`.utd`) typed generic wrapper.
2//!
3//! Doors are interactive pathways. Beyond acting as barriers and transitions
4//! between areas, they carry lock mechanics, trap configuration, script hooks
5//! and visual state (open, destroyed, jammed).
6//!
7//! ## Scope of this slice
8//! - Typed access for all door identity/state/script/link fields.
9//! - K1-aligned read defaults for door booleans observed in `LoadDoor`.
10//! - Superset-correct: all fields are modeled, no passthrough needed.
11//!
12//! ## Field Layout (simplified)
13//! ```text
14//! UTD root struct
15//! +-- TemplateResRef / Tag / LocName / Description
16//! +-- GenericType / Appearance / OpenState
17//! +-- Lock + trap + durability fields
18//! +-- Script hooks (OnClosed/OnDamaged/...)
19//! +-- Transition fields (LinkedTo/LinkedToFlags/LinkedToModule/TransitionDestin)
20//! ```
21//!
22//! ## The lock and saving-throw fields are declared here, not shared
23//!
24//! A door and a placeable carry the same seven lock labels and the same three
25//! saving throws, and a shared block cannot carry a schema for them: every one
26//! of those ten labels is recorded `constructed` on the door against `stamped`
27//! on the placeable, so a single entry would state the wrong mechanism for one
28//! of them. Across the whole audit that pattern is not confined to the
29//! mechanism. A fifth of the paths two views share resolve to a different
30//! value depending on the owning type.
31//! A shared reader is still right, since the value is the same; the record of
32//! how the engine reaches it is not shareable.
33
34use std::io::{Cursor, Read, Write};
35
36use crate::shared::{PORTRAIT_ID_USE_RESREF, SCRIPT_SLOT_SEED, TRAP_TYPE_ABSENT};
37use rakata_core::ResRef;
38use rakata_formats::gff::get_locstring;
39use rakata_formats::schema::FromGff;
40use rakata_formats::GENERIC_FILE_TYPE;
41use rakata_formats::{
42    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
43    GffStruct,
44};
45use thiserror::Error;
46
47/// Typed UTD model built from/to [`Gff`] data.
48#[derive(Debug, Clone, PartialEq, GffModel)]
49pub struct Utd {
50    /// Door template resref (`TemplateResRef`).
51    #[gff(TemplateResRef, unexamined)]
52    pub template_resref: ResRef,
53    /// Door tag (`Tag`).
54    #[gff(Tag, constructed)]
55    pub tag: String,
56    /// Localized door name (`LocName`).
57    #[gff(LocName, stamped)]
58    pub name: GffLocalizedString,
59    /// Localized door description (`Description`).
60    #[gff(Description, stamped)]
61    pub description: GffLocalizedString,
62    /// Toolset comment (`Comment`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
63    #[gff(Comment, unexamined)]
64    pub comment: String,
65    /// Conversation resref (`Conversation`).
66    #[gff(Conversation, stamped)]
67    pub conversation: ResRef,
68    /// Faction identifier (`Faction`).
69    #[gff(Faction, constructed)]
70    pub faction_id: u32,
71    /// Generic door type id (`GenericType`).
72    #[gff(GenericType, constructed)]
73    pub appearance_id: u8,
74    /// Optional appearance table index (`Appearance`). Engine truncates to a single byte; values above 255 wrap to 0 and break door model rendering.
75    #[gff(Appearance, unexamined, range_int = (0, 255))]
76    pub unused_appearance_id: u32,
77    /// Open state (`OpenState`).
78    #[gff(OpenState, constructed, omit = audited_constant(575))]
79    pub open_state: u8,
80    /// Animation state (`AnimationState`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
81    #[gff(AnimationState, unexamined)]
82    pub animation_state: u8,
83    /// Door bearing in radians (`Bearing`).
84    #[gff(Bearing, constructed, omit = audited_constant(575))]
85    pub bearing: f32,
86    /// Whether the object can be locked at all (`Lockable`).
87    #[gff(Lockable, unexamined)]
88    pub lockable: bool,
89    /// Whether the object is currently locked (`Locked`).
90    #[gff(Locked, constructed)]
91    pub locked: bool,
92    /// Whether opening requires the named key (`KeyRequired`).
93    #[gff(KeyRequired, constructed)]
94    pub key_required: bool,
95    /// Tag of the key that opens it (`KeyName`).
96    #[gff(KeyName, unexamined)]
97    pub key_name: String,
98    /// Whether the key is consumed on use (`AutoRemoveKey`).
99    #[gff(AutoRemoveKey, constructed)]
100    pub auto_remove_key: bool,
101    /// Security DC to pick the lock (`OpenLockDC`).
102    #[gff(OpenLockDC, constructed)]
103    pub open_lock_dc: u8,
104    /// Security DC to relock it (`CloseLockDC`).
105    #[gff(CloseLockDC, constructed)]
106    pub close_lock_dc: u8,
107    /// Secret door detect DC (`SecretDoorDC`).
108    #[gff(SecretDoorDC, constructed, omit = audited_constant(575))]
109    pub secret_door_dc: u8,
110    /// Open lock difficulty (`OpenLockDiff`, K2-oriented field).
111    #[gff(
112        OpenLockDiff,
113        read_only_dead = "a full-text search of LoadDoor's decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them",
114        not_a_constant
115    )]
116    pub open_lock_diff: u8,
117    /// Open lock difficulty modifier (`OpenLockDiffMod`, K2-oriented field).
118    #[gff(
119        OpenLockDiffMod,
120        read_only_dead = "a full-text search of LoadDoor's decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them",
121        not_a_constant
122    )]
123    pub open_lock_diff_mod: i8,
124    /// Current hit points (`CurrentHP`). Engine clamps this to `maximum_hp` on template load.
125    #[gff(CurrentHP, unexamined)]
126    pub current_hp: i16,
127    /// Maximum hit points (`HP`).
128    #[gff(HP, constructed = 1)]
129    pub maximum_hp: i16,
130    /// Hardness (`Hardness`).
131    #[gff(Hardness, stamped)]
132    pub hardness: u8,
133    /// Fortitude save (`Fort`).
134    #[gff(Fort, constructed)]
135    pub fortitude: u8,
136    /// Reflex save (`Ref`).
137    #[gff(Ref, constructed)]
138    pub reflex: u8,
139    /// Will save (`Will`).
140    #[gff(Will, constructed)]
141    pub will: u8,
142    /// Plot flag (`Plot`). If `is_static` is true, the engine will force this to true at runtime.
143    #[gff(Plot, constructed)]
144    pub plot: bool,
145    /// Invulnerable flag (`Invulnerable`).
146    #[gff(Invulnerable, constructed, omit = audited_constant(575))]
147    pub invulnerable: bool,
148    /// Min-1HP flag (`Min1HP`).
149    #[gff(Min1HP, constructed)]
150    pub min1_hp: bool,
151    /// Static flag (`Static`).
152    #[gff(Static, stamped)]
153    pub is_static: bool,
154    /// Not-blastable flag (`NotBlastable`, K2-oriented field).
155    #[gff(
156        NotBlastable,
157        read_only_dead = "a full-text search of LoadDoor's decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them",
158        not_a_constant
159    )]
160    pub not_blastable: bool,
161    /// Interruptable flag (`Interruptable`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
162    #[gff(Interruptable, unexamined)]
163    pub interruptable: bool,
164    /// 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`.
165    #[gff(PortraitId, stamped = PORTRAIT_ID_USE_RESREF)]
166    pub portrait_id: u16,
167    /// Portrait resref (`Portrait`). Ignored by the engine if `portrait_id` is `< 0xFFFE`.
168    #[gff(Portrait, stamped)]
169    pub portrait: ResRef,
170    /// Palette ID (`PaletteID`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
171    #[gff(PaletteID, unexamined)]
172    pub palette_id: u8,
173    /// Trap-detectable flag (`TrapDetectable`).
174    #[gff(TrapDetectable, constructed = true)]
175    pub trap_detectable: bool,
176    /// Trap detect DC (`TrapDetectDC`).
177    #[gff(TrapDetectDC, constructed)]
178    pub trap_detect_dc: u8,
179    /// Trap-disarmable flag (`TrapDisarmable`).
180    #[gff(TrapDisarmable, constructed = true)]
181    pub trap_disarmable: bool,
182    /// Trap disarm DC (`DisarmDC`).
183    #[gff(DisarmDC, constructed)]
184    pub trap_disarm_dc: u8,
185    /// Trap flag (`TrapFlag`).
186    #[gff(TrapFlag, constructed)]
187    pub trap_flag: u8,
188    /// Trap one-shot flag (`TrapOneShot`).
189    #[gff(TrapOneShot, constructed = true)]
190    pub trap_one_shot: bool,
191    /// Trap type (`TrapType`).
192    #[gff(TrapType, constructed = TRAP_TYPE_ABSENT)]
193    pub trap_type: u8,
194    /// On-closed script (`OnClosed`).
195    #[gff(OnClosed, constructed = SCRIPT_SLOT_SEED)]
196    pub on_closed: ResRef,
197    /// On-damaged script (`OnDamaged`).
198    #[gff(OnDamaged, constructed = SCRIPT_SLOT_SEED)]
199    pub on_damaged: ResRef,
200    /// On-death script (`OnDeath`).
201    #[gff(OnDeath, constructed = SCRIPT_SLOT_SEED)]
202    pub on_death: ResRef,
203    /// On-disarm script (`OnDisarm`).
204    #[gff(OnDisarm, constructed = SCRIPT_SLOT_SEED)]
205    pub on_disarm: ResRef,
206    /// On-heartbeat script (`OnHeartbeat`).
207    #[gff(OnHeartbeat, constructed = SCRIPT_SLOT_SEED)]
208    pub on_heartbeat: ResRef,
209    /// On-lock script (`OnLock`).
210    #[gff(OnLock, constructed = SCRIPT_SLOT_SEED)]
211    pub on_lock: ResRef,
212    /// On-melee-attacked script (`OnMeleeAttacked`).
213    #[gff(OnMeleeAttacked, constructed = SCRIPT_SLOT_SEED)]
214    pub on_melee_attacked: ResRef,
215    /// On-open script (`OnOpen`).
216    #[gff(OnOpen, constructed = SCRIPT_SLOT_SEED)]
217    pub on_open: ResRef,
218    /// On-spell-cast-at script (`OnSpellCastAt`).
219    #[gff(OnSpellCastAt, constructed = SCRIPT_SLOT_SEED)]
220    pub on_spell_cast_at: ResRef,
221    /// 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`.
222    #[gff(OnTrapTriggered, constructed = SCRIPT_SLOT_SEED)]
223    pub on_trap_triggered: ResRef,
224    /// On-unlock script (`OnUnlock`).
225    #[gff(OnUnlock, constructed = SCRIPT_SLOT_SEED)]
226    pub on_unlock: ResRef,
227    /// On-user-defined script (`OnUserDefined`).
228    #[gff(OnUserDefined, constructed = SCRIPT_SLOT_SEED)]
229    pub on_user_defined: ResRef,
230    /// On-click script (`OnClick`).
231    #[gff(OnClick, constructed = SCRIPT_SLOT_SEED)]
232    pub on_click: ResRef,
233    /// On-open-failed script (`OnFailToOpen`).
234    #[gff(OnFailToOpen, constructed = SCRIPT_SLOT_SEED)]
235    pub on_fail_to_open: ResRef,
236    /// On-dialog script (`OnDialog`, K2-oriented field).
237    #[gff(OnDialog, constructed = SCRIPT_SLOT_SEED, omit = audited_constant(575))]
238    pub on_dialog: ResRef,
239    /// Linked target flags (`LinkedToFlags`).
240    #[gff(
241        LinkedToFlags,
242        read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
243        unexamined
244    )]
245    pub linked_to_flags: u8,
246    /// Linked target tag (`LinkedTo`).
247    #[gff(
248        LinkedTo,
249        read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
250        unexamined
251    )]
252    pub linked_to: String,
253    /// Linked target module (`LinkedToModule`).
254    #[gff(
255        LinkedToModule,
256        read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
257        unexamined
258    )]
259    pub linked_to_module: ResRef,
260    /// Localized transition destination (`TransitionDestin`).
261    ///
262    /// The engine truncates the name to the 16-byte GFF label limit. Vanilla
263    /// `.utd` blueprints do not carry this field at all; it shows up on the
264    /// door and trigger instances inside a module `GIT`.
265    #[gff(
266        TransitionDestin,
267        manual_read,
268        read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
269        unexamined
270    )]
271    pub transition_destination: GffLocalizedString,
272    /// Load screen id (`LoadScreenID`).
273    #[gff(LoadScreenID, stamped)]
274    pub loadscreen_id: u16,
275}
276
277impl Utd {
278    /// Creates an empty UTD value.
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Builds typed UTD data from a parsed GFF container.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`UtdError::UnsupportedFileType`] for a container that is
288    /// neither `UTD ` nor the generic `GFF ` form.
289    pub fn from_gff(gff: &Gff) -> Result<Self, UtdError> {
290        if gff.file_type != <Utd as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
291            return Err(UtdError::UnsupportedFileType(gff.file_type));
292        }
293
294        let root = &gff.root;
295        let mut utd = Self::read_declared(root);
296
297        // The canonical spelling is the only one a binary GFF can carry, since
298        // the other two run past the 16-byte label limit. They reach us from
299        // sources that are not bound by it, so the chain is a tolerance rather
300        // than an alternative the format offers.
301        if let Some(destination) = get_locstring(root, "TransitionDestin")
302            .or_else(|| get_locstring(root, "TransitionDestination"))
303            .or_else(|| get_locstring(root, "TransDest"))
304        {
305            utd.transition_destination = destination.clone();
306        }
307
308        Ok(utd)
309    }
310
311    /// Converts this typed UTD value into a GFF container.
312    pub fn to_gff(&self) -> Gff {
313        let mut root = GffStruct::new(-1);
314        self.write_declared(&mut root);
315        Gff::new(*b"UTD ", root)
316    }
317}
318
319/// Errors produced while reading or writing typed UTD data.
320#[derive(Debug, Error)]
321pub enum UtdError {
322    /// Source file type is not supported by this parser.
323    #[error("unsupported UTD file type: {0:?}")]
324    UnsupportedFileType([u8; 4]),
325    /// Underlying GFF parser/writer error.
326    #[error(transparent)]
327    Gff(#[from] GffBinaryError),
328}
329
330/// Reads typed UTD data from a reader at the current stream position.
331///
332/// # Errors
333///
334/// [`UtdError::Gff`] when the stream is not a readable GFF, and
335/// [`UtdError::UnsupportedFileType`] when it is a GFF of some other format,
336/// carrying the fourcc that was found.
337#[cfg_attr(
338    feature = "tracing",
339    tracing::instrument(level = "debug", skip(reader))
340)]
341pub fn read_utd<R: Read>(reader: &mut R) -> Result<Utd, UtdError> {
342    let gff = read_gff(reader)?;
343    Utd::from_gff(&gff)
344}
345
346/// Reads typed UTD data directly from bytes.
347///
348/// # Errors
349///
350/// [`UtdError::Gff`] when `bytes` are not a readable GFF, and
351/// [`UtdError::UnsupportedFileType`] when they are a GFF of some other format,
352/// carrying the fourcc that was found.
353#[cfg_attr(
354    feature = "tracing",
355    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
356)]
357pub fn read_utd_from_bytes(bytes: &[u8]) -> Result<Utd, UtdError> {
358    let gff = read_gff_from_bytes(bytes)?;
359    Utd::from_gff(&gff)
360}
361
362/// Authors the UTD file the typed view describes, into a writer.
363///
364/// # Errors
365///
366/// [`UtdError::Gff`] when the writer fails or a value will not encode. The
367/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
368/// this side.
369#[cfg_attr(
370    feature = "tracing",
371    tracing::instrument(level = "debug", skip(writer, utd))
372)]
373pub fn author_utd<W: Write>(writer: &mut W, utd: &Utd) -> Result<(), UtdError> {
374    let gff = utd.to_gff();
375    write_gff(writer, &gff)?;
376    Ok(())
377}
378
379/// Authors the UTD file the typed view describes, as bytes.
380///
381/// # Errors
382///
383/// [`UtdError::Gff`] when a value will not encode. Writing into a `Vec` has no
384/// I/O to fail at.
385#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utd)))]
386pub fn author_utd_to_vec(utd: &Utd) -> Result<Vec<u8>, UtdError> {
387    let mut cursor = Cursor::new(Vec::new());
388    author_utd(&mut cursor, utd)?;
389    Ok(cursor.into_inner())
390}
391
392#[cfg(test)]
393mod tests {
394    #[test]
395    fn a_transition_destination_survives_a_round_trip() {
396        // Both halves can go dead without failing. `TransitionDestination`
397        // exceeds the 16-byte GFF label limit and so never appears, so a
398        // reader trying only that spelling finds nothing; a writer whose
399        // condition tests the freshly-built output struct rather than the
400        // source never fires. Together they drop a door's transition text on
401        // read and on write.
402        let mut root = GffStruct::new(-1);
403        root.push_field(
404            gff_label!("TransitionDestin"),
405            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(1234))),
406        );
407
408        let utd = Utd::from_gff(&Gff::new(*b"UTD ", root)).expect("parses");
409        assert_eq!(
410            utd.transition_destination.string_ref,
411            StrRef::from_raw(1234)
412        );
413
414        let written = utd.to_gff();
415        assert!(written.root.field("TransitionDestin").is_some());
416
417        let reparsed = Utd::from_gff(&written).expect("reparses");
418        assert_eq!(reparsed.transition_destination, utd.transition_destination);
419    }
420
421    use super::*;
422    use rakata_core::StrRef;
423    use rakata_formats::schema::HasSchema;
424    use rakata_formats::{gff_label, GffValue};
425
426    const TEST_UTD: &[u8] = include_bytes!(concat!(
427        env!("CARGO_MANIFEST_DIR"),
428        "/../../fixtures/test.utd"
429    ));
430
431    #[test]
432    fn reads_core_utd_fields_from_fixture() {
433        let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
434
435        assert_eq!(utd.tag, "TelosDoor13");
436        assert_eq!(utd.template_resref, "door_tel014");
437        assert_eq!(utd.name.string_ref.raw(), 123_731);
438        assert_eq!(utd.description.string_ref.raw(), -1);
439        assert!(utd.auto_remove_key);
440        assert_eq!(utd.close_lock_dc, 0);
441        assert_eq!(utd.conversation, "convoresref");
442        assert!(utd.interruptable);
443        assert_eq!(utd.faction_id, 1);
444        assert!(utd.plot);
445        assert!(utd.not_blastable);
446        assert!(utd.min1_hp);
447        assert!(utd.key_required);
448        assert!(utd.lockable);
449        assert!(utd.locked);
450        assert_eq!(utd.open_lock_dc, 28);
451        assert_eq!(utd.open_lock_diff, 1);
452        assert_eq!(utd.open_lock_diff_mod, 1);
453        assert_eq!(utd.portrait_id, 0);
454        assert!(utd.trap_detectable);
455        assert_eq!(utd.trap_detect_dc, 0);
456        assert!(utd.trap_disarmable);
457        assert_eq!(utd.trap_disarm_dc, 28);
458        assert_eq!(utd.trap_flag, 0);
459        assert!(utd.trap_one_shot);
460        assert_eq!(utd.trap_type, 2);
461        assert_eq!(utd.key_name, "keyname");
462        assert_eq!(utd.animation_state, 1);
463        assert_eq!(utd.unused_appearance_id, 1);
464        assert_eq!(utd.maximum_hp, 20);
465        assert_eq!(utd.current_hp, 60);
466        assert_eq!(utd.hardness, 5);
467        assert_eq!(utd.fortitude, 28);
468        assert_eq!(utd.reflex, 0);
469        assert_eq!(utd.will, 0);
470        assert_eq!(utd.on_closed, "onclosed");
471        assert_eq!(utd.on_damaged, "ondamaged");
472        assert_eq!(utd.on_death, "ondeath");
473        assert_eq!(utd.on_disarm, "ondisarm");
474        assert_eq!(utd.on_heartbeat, "onheartbeat");
475        assert_eq!(utd.on_lock, "onlock");
476        assert_eq!(utd.on_melee_attacked, "onmeleeattacked");
477        assert_eq!(utd.on_open, "onopen");
478        assert_eq!(utd.on_spell_cast_at, "onspellcastat");
479        assert_eq!(utd.on_trap_triggered, "ontraptriggered");
480        assert_eq!(utd.on_unlock, "onunlock");
481        assert_eq!(utd.on_user_defined, "onuserdefined");
482        assert_eq!(utd.loadscreen_id, 0);
483        assert_eq!(utd.appearance_id, 110);
484        assert!(utd.is_static);
485        assert_eq!(utd.open_state, 1);
486        assert_eq!(utd.on_click, "onclick");
487        assert_eq!(utd.on_fail_to_open, "onfailtoopen");
488        assert_eq!(utd.comment, "abcdefg");
489        assert_eq!(utd.palette_id, 1);
490    }
491
492    #[test]
493    fn all_fields_survive_typed_roundtrip() {
494        let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
495        let bytes = author_utd_to_vec(&utd).expect("write succeeds");
496        let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
497        assert_eq!(reparsed, utd);
498    }
499
500    #[test]
501    fn typed_edits_roundtrip_through_gff_writer() {
502        let mut utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
503        utd.tag = "TelosDoor13_Rust".into();
504        utd.open_state = 2;
505        utd.on_open = ResRef::new("rust_on_open").expect("valid resref literal");
506
507        let bytes = author_utd_to_vec(&utd).expect("write succeeds");
508        let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
509
510        assert_eq!(reparsed.tag, "TelosDoor13_Rust");
511        assert_eq!(reparsed.open_state, 2);
512        assert_eq!(reparsed.on_open, "rust_on_open");
513    }
514
515    #[test]
516    fn read_utd_from_reader_matches_bytes_path() {
517        let mut cursor = Cursor::new(TEST_UTD);
518        let from_reader = read_utd(&mut cursor).expect("reader parse succeeds");
519        let from_bytes = read_utd_from_bytes(TEST_UTD).expect("bytes parse succeeds");
520
521        assert_eq!(from_reader, from_bytes);
522    }
523
524    #[test]
525    fn rejects_non_utd_file_type() {
526        let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
527        gff.file_type = *b"UTC ";
528
529        let err = Utd::from_gff(&gff).expect_err("UTC must be rejected as UTD input");
530        assert!(matches!(
531            err,
532            UtdError::UnsupportedFileType(file_type) if file_type == *b"UTC "
533        ));
534    }
535
536    #[test]
537    fn a_mistyped_transition_destination_reads_as_absent() {
538        let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
539        gff.root.fields.retain(|field| field.label != "TransDest");
540        gff.root
541            .push_field(gff_label!("TransDest"), GffValue::UInt32(99));
542
543        let utd = Utd::from_gff(&gff).expect("a mistyped field is not a read failure");
544
545        assert_eq!(utd.transition_destination, GffLocalizedString::default());
546    }
547
548    #[test]
549    fn write_utd_matches_direct_gff_writer() {
550        let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
551
552        let via_typed = author_utd_to_vec(&utd).expect("typed write succeeds");
553
554        let mut direct = Cursor::new(Vec::new());
555        write_gff(&mut direct, &utd.to_gff()).expect("direct write succeeds");
556
557        assert_eq!(via_typed, direct.into_inner());
558    }
559
560    #[test]
561    fn schema_field_count() {
562        assert_eq!(Utd::schema().len(), 64); // 34 core + 7 trap + 15 scripts + 8 toolset
563    }
564
565    #[test]
566    fn schema_no_duplicate_labels() {
567        let mut labels: Vec<&str> = Utd::schema().iter().map(|f| f.label.as_str()).collect();
568        labels.sort_unstable();
569        let before = labels.len();
570        labels.dedup();
571        assert_eq!(before, labels.len(), "duplicate labels in UTD schema");
572    }
573}