rakata_generics/
utt.rs

1//! UTT (`.utt`) typed generic wrapper.
2//!
3//! UTT resources are GFF-backed trigger templates.
4//!
5//! ## Field Layout (simplified)
6//! ```text
7//! UTT root struct
8//! +-- TemplateResRef / Tag / LocalizedName / Comment
9//! +-- Cursor / Type / Faction / HighlightHeight
10//! +-- AutoRemoveKey / KeyName
11//! +-- TrapDetectable / TrapDetectDC / TrapDisarmable / DisarmDC / TrapFlag / TrapOneShot / TrapType
12//! +-- Script hooks (OnDisarm/OnTrapTriggered/OnClick/ScriptHeartbeat/ScriptOnEnter/ScriptOnExit/ScriptUserDefine)
13//! +-- LinkedTo / LinkedToFlags / LinkedToModule / PartyRequired / SetByPlayerParty
14//! +-- TransitionDestin (localized destination text)
15//! +-- PortraitId / Portrait / LoadScreenID / PaletteID
16//! ```
17
18use std::io::{Cursor, Read, Write};
19
20use crate::gff_helpers::{
21    get_bool, get_f32, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32, get_u8,
22    upsert_field,
23};
24use crate::shared::{GitTriggerPoint, TrapSettings};
25use rakata_core::{ResRef, StrRef};
26use rakata_formats::{
27    gff_schema::{FieldSchema, GffSchema, GffType},
28    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
29    GffValue,
30};
31use thiserror::Error;
32
33/// Typed UTT model built from/to [`Gff`] data.
34#[derive(Debug, Clone, PartialEq)]
35pub struct Utt {
36    /// Trigger template resref (`TemplateResRef`).
37    pub template_resref: ResRef,
38    /// Trigger tag (`Tag`).
39    pub tag: String,
40    /// Localized trigger name (`LocalizedName`).
41    pub name: GffLocalizedString,
42    /// Toolset comment (`Comment`).
43    pub comment: String,
44    /// Auto-remove-key flag (`AutoRemoveKey`).
45    pub auto_remove_key: bool,
46    /// Faction identifier (`Faction`).
47    pub faction_id: u32,
48    /// Cursor identifier (`Cursor`).
49    pub cursor_id: u8,
50    /// Highlight height (`HighlightHeight`).
51    pub highlight_height: f32,
52    /// Key name (`KeyName`).
53    pub key_name: String,
54    /// Trigger type id (`Type`).
55    pub type_id: i32,
56    /// Trap-detectable flag (`TrapDetectable`).
57    pub trap_detectable: bool,
58    /// Trap detect DC (`TrapDetectDC`).
59    pub trap_detect_dc: u8,
60    /// Trap-disarmable flag (`TrapDisarmable`).
61    pub trap_disarmable: bool,
62    /// Trap disarm DC (`DisarmDC`).
63    pub trap_disarm_dc: u8,
64    /// Trap enabled flag (`TrapFlag`).
65    pub is_trap: bool,
66    /// Trap one-shot flag (`TrapOneShot`).
67    pub trap_one_shot: bool,
68    /// Trap type (`TrapType`).
69    pub trap_type: u8,
70    /// On-disarm script (`OnDisarm`).
71    pub on_disarm: ResRef,
72    /// On-trap-triggered script (`OnTrapTriggered`).
73    pub on_trap_triggered: ResRef,
74    /// On-click script (`OnClick`).
75    pub on_click: ResRef,
76    /// On-heartbeat script (`ScriptHeartbeat`).
77    pub on_heartbeat: ResRef,
78    /// On-enter script (`ScriptOnEnter`).
79    pub on_enter: ResRef,
80    /// On-exit script (`ScriptOnExit`).
81    pub on_exit: ResRef,
82    /// On-user-defined script (`ScriptUserDefine`).
83    pub on_user_defined: ResRef,
84    /// Linked target tag (`LinkedTo`).
85    pub linked_to: String,
86    /// Linked target flags (`LinkedToFlags`).
87    pub linked_to_flags: u8,
88    /// Linked target module resref (`LinkedToModule`).
89    pub linked_to_module: ResRef,
90    /// Localized transition destination text (`TransitionDestin`).
91    pub transition_destination: GffLocalizedString,
92    /// Party-required flag (`PartyRequired`).
93    pub party_required: bool,
94    /// Set-by-player-party flag (`SetByPlayerParty`).
95    pub set_by_player_party: bool,
96    /// Portrait ID (`PortraitId`).
97    pub portrait_id: u16,
98    /// Portrait resref (`Portrait`).
99    pub portrait: ResRef,
100    /// Loadscreen ID (`LoadScreenID`).
101    pub loadscreen_id: u16,
102    /// Palette ID (`PaletteID`).
103    pub palette_id: u8,
104    /// Geometry polygon vertices (`Geometry`).
105    pub geometry: Vec<GitTriggerPoint>,
106}
107
108impl Default for Utt {
109    fn default() -> Self {
110        Self {
111            template_resref: ResRef::blank(),
112            tag: String::new(),
113            name: GffLocalizedString::new(StrRef::invalid()),
114            comment: String::new(),
115            auto_remove_key: false,
116            faction_id: 0,
117            cursor_id: 0,
118            highlight_height: 0.0,
119            key_name: String::new(),
120            type_id: 0,
121            trap_detectable: false,
122            trap_detect_dc: 0,
123            trap_disarmable: false,
124            trap_disarm_dc: 0,
125            is_trap: false,
126            trap_one_shot: false,
127            trap_type: 0,
128            on_disarm: ResRef::blank(),
129            on_trap_triggered: ResRef::blank(),
130            on_click: ResRef::blank(),
131            on_heartbeat: ResRef::blank(),
132            on_enter: ResRef::blank(),
133            on_exit: ResRef::blank(),
134            on_user_defined: ResRef::blank(),
135            linked_to: String::new(),
136            linked_to_flags: 0,
137            linked_to_module: ResRef::blank(),
138            transition_destination: GffLocalizedString::new(StrRef::invalid()),
139            party_required: false,
140            set_by_player_party: false,
141            portrait_id: 0,
142            portrait: ResRef::blank(),
143            loadscreen_id: 0,
144            palette_id: 0,
145            geometry: Vec::new(),
146        }
147    }
148}
149
150impl Utt {
151    /// Creates an empty UTT value.
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Returns trap-related settings as a shared typed block.
157    pub fn trap_settings(&self) -> TrapSettings {
158        TrapSettings {
159            detectable: self.trap_detectable,
160            detect_dc: self.trap_detect_dc,
161            disarmable: self.trap_disarmable,
162            disarm_dc: self.trap_disarm_dc,
163            flag: u8::from(self.is_trap),
164            one_shot: self.trap_one_shot,
165            trap_type: self.trap_type,
166        }
167    }
168
169    /// Applies trap-related settings from a shared typed block.
170    pub fn set_trap_settings(&mut self, trap: TrapSettings) {
171        self.trap_detectable = trap.detectable;
172        self.trap_detect_dc = trap.detect_dc;
173        self.trap_disarmable = trap.disarmable;
174        self.trap_disarm_dc = trap.disarm_dc;
175        self.is_trap = trap.flag != 0;
176        self.trap_one_shot = trap.one_shot;
177        self.trap_type = trap.trap_type;
178    }
179
180    /// Builds typed UTT data from a parsed GFF container.
181    pub fn from_gff(gff: &Gff) -> Result<Self, UttError> {
182        if gff.file_type != *b"UTT " && gff.file_type != *b"GFF " {
183            return Err(UttError::UnsupportedFileType(gff.file_type));
184        }
185
186        let root = &gff.root;
187        let trap = TrapSettings::read(|label| get_bool(root, label), |label| get_u8(root, label));
188
189        let geometry = match root.field("Geometry") {
190            Some(GffValue::List(elements)) => elements
191                .iter()
192                .map(GitTriggerPoint::from_gff_struct)
193                .collect(),
194            _ => Vec::new(),
195        };
196
197        if matches!(root.field("LocalizedName"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
198        {
199            return Err(UttError::TypeMismatch {
200                field: "LocalizedName",
201                expected: "LocalizedString",
202            });
203        }
204
205        Ok(Self {
206            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
207            tag: get_string(root, "Tag").unwrap_or_default(),
208            name: get_locstring(root, "LocalizedName")
209                .cloned()
210                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
211            comment: get_string(root, "Comment").unwrap_or_default(),
212            auto_remove_key: get_bool(root, "AutoRemoveKey").unwrap_or(false),
213            faction_id: get_u32(root, "Faction").unwrap_or(0),
214            cursor_id: get_u8(root, "Cursor").unwrap_or(0),
215            highlight_height: get_f32(root, "HighlightHeight").unwrap_or(0.0),
216            key_name: get_string(root, "KeyName").unwrap_or_default(),
217            type_id: get_i32(root, "Type").unwrap_or(0),
218            trap_detectable: trap.detectable,
219            trap_detect_dc: trap.detect_dc,
220            trap_disarmable: trap.disarmable,
221            trap_disarm_dc: trap.disarm_dc,
222            is_trap: trap.flag != 0,
223            trap_one_shot: trap.one_shot,
224            trap_type: trap.trap_type,
225            on_disarm: get_resref(root, "OnDisarm").unwrap_or_default(),
226            on_trap_triggered: get_resref(root, "OnTrapTriggered").unwrap_or_default(),
227            on_click: get_resref(root, "OnClick").unwrap_or_default(),
228            on_heartbeat: get_resref(root, "ScriptHeartbeat").unwrap_or_default(),
229            on_enter: get_resref(root, "ScriptOnEnter").unwrap_or_default(),
230            on_exit: get_resref(root, "ScriptOnExit").unwrap_or_default(),
231            on_user_defined: get_resref(root, "ScriptUserDefine").unwrap_or_default(),
232            linked_to: get_string(root, "LinkedTo").unwrap_or_default(),
233            linked_to_flags: get_u8(root, "LinkedToFlags").unwrap_or(0),
234            linked_to_module: get_resref(root, "LinkedToModule").unwrap_or_default(),
235            transition_destination: get_locstring(root, "TransitionDestin")
236                .cloned()
237                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
238            party_required: get_bool(root, "PartyRequired").unwrap_or(false),
239            set_by_player_party: get_bool(root, "SetByPlayerParty").unwrap_or(false),
240            portrait_id: get_u16(root, "PortraitId").unwrap_or(0),
241            portrait: get_resref(root, "Portrait").unwrap_or_default(),
242            loadscreen_id: get_u16(root, "LoadScreenID").unwrap_or(0),
243            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
244            geometry,
245        })
246    }
247
248    /// Converts this typed UTT value into a GFF container.
249    pub fn to_gff(&self) -> Gff {
250        let mut root = GffStruct::new(-1);
251
252        upsert_field(
253            &mut root,
254            "TemplateResRef",
255            GffValue::ResRef(self.template_resref),
256        );
257        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
258        upsert_field(
259            &mut root,
260            "LocalizedName",
261            GffValue::LocalizedString(self.name.clone()),
262        );
263        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
264        upsert_field(
265            &mut root,
266            "AutoRemoveKey",
267            GffValue::UInt8(u8::from(self.auto_remove_key)),
268        );
269        upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
270        upsert_field(&mut root, "Cursor", GffValue::UInt8(self.cursor_id));
271        upsert_field(
272            &mut root,
273            "HighlightHeight",
274            GffValue::Single(self.highlight_height),
275        );
276        upsert_field(
277            &mut root,
278            "KeyName",
279            GffValue::String(self.key_name.clone()),
280        );
281        upsert_field(&mut root, "Type", GffValue::Int32(self.type_id));
282
283        self.trap_settings()
284            .write(|label, value| upsert_field(&mut root, label, value));
285        upsert_field(&mut root, "OnDisarm", GffValue::ResRef(self.on_disarm));
286        upsert_field(
287            &mut root,
288            "OnTrapTriggered",
289            GffValue::ResRef(self.on_trap_triggered),
290        );
291        upsert_field(&mut root, "OnClick", GffValue::ResRef(self.on_click));
292        upsert_field(
293            &mut root,
294            "ScriptHeartbeat",
295            GffValue::ResRef(self.on_heartbeat),
296        );
297        upsert_field(&mut root, "ScriptOnEnter", GffValue::ResRef(self.on_enter));
298        upsert_field(&mut root, "ScriptOnExit", GffValue::ResRef(self.on_exit));
299        upsert_field(
300            &mut root,
301            "ScriptUserDefine",
302            GffValue::ResRef(self.on_user_defined),
303        );
304
305        upsert_field(
306            &mut root,
307            "LinkedTo",
308            GffValue::String(self.linked_to.clone()),
309        );
310        upsert_field(
311            &mut root,
312            "LinkedToFlags",
313            GffValue::UInt8(self.linked_to_flags),
314        );
315        upsert_field(
316            &mut root,
317            "LinkedToModule",
318            GffValue::ResRef(self.linked_to_module),
319        );
320        upsert_field(
321            &mut root,
322            "TransitionDestin",
323            GffValue::LocalizedString(self.transition_destination.clone()),
324        );
325        upsert_field(
326            &mut root,
327            "PartyRequired",
328            GffValue::UInt8(u8::from(self.party_required)),
329        );
330        upsert_field(
331            &mut root,
332            "SetByPlayerParty",
333            GffValue::UInt8(u8::from(self.set_by_player_party)),
334        );
335        upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
336        upsert_field(&mut root, "Portrait", GffValue::ResRef(self.portrait));
337        upsert_field(
338            &mut root,
339            "LoadScreenID",
340            GffValue::UInt16(self.loadscreen_id),
341        );
342        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
343
344        let geometry_list: Vec<rakata_formats::GffStruct> =
345            self.geometry.iter().map(|p| p.to_gff_struct()).collect();
346        upsert_field(&mut root, "Geometry", GffValue::List(geometry_list));
347
348        Gff::new(*b"UTT ", root)
349    }
350}
351
352/// Errors produced while reading or writing typed UTT data.
353#[derive(Debug, Error)]
354pub enum UttError {
355    /// Source file type is not supported by this parser.
356    #[error("unsupported UTT file type: {0:?}")]
357    UnsupportedFileType([u8; 4]),
358    /// A required container field had an unexpected runtime type.
359    #[error("UTT field `{field}` has incompatible type (expected {expected})")]
360    TypeMismatch {
361        /// Field label where mismatch occurred.
362        field: &'static str,
363        /// Expected runtime value kind.
364        expected: &'static str,
365    },
366    /// Underlying GFF parser/writer error.
367    #[error(transparent)]
368    Gff(#[from] GffBinaryError),
369}
370
371/// Reads typed UTT data from a reader at the current stream position.
372#[cfg_attr(
373    feature = "tracing",
374    tracing::instrument(level = "debug", skip(reader))
375)]
376pub fn read_utt<R: Read>(reader: &mut R) -> Result<Utt, UttError> {
377    let gff = read_gff(reader)?;
378    Utt::from_gff(&gff)
379}
380
381/// Reads typed UTT data directly from bytes.
382#[cfg_attr(
383    feature = "tracing",
384    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
385)]
386pub fn read_utt_from_bytes(bytes: &[u8]) -> Result<Utt, UttError> {
387    let gff = read_gff_from_bytes(bytes)?;
388    Utt::from_gff(&gff)
389}
390
391/// Writes typed UTT data to an output writer.
392#[cfg_attr(
393    feature = "tracing",
394    tracing::instrument(level = "debug", skip(writer, utt))
395)]
396pub fn write_utt<W: Write>(writer: &mut W, utt: &Utt) -> Result<(), UttError> {
397    let gff = utt.to_gff();
398    write_gff(writer, &gff)?;
399    Ok(())
400}
401
402/// Serializes typed UTT data into a byte vector.
403#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utt)))]
404pub fn write_utt_to_vec(utt: &Utt) -> Result<Vec<u8>, UttError> {
405    let mut cursor = Cursor::new(Vec::new());
406    write_utt(&mut cursor, utt)?;
407    Ok(cursor.into_inner())
408}
409
410/// UTT `Geometry` list entry child schema.
411static GEOMETRY_CHILDREN: &[FieldSchema] = &[
412    FieldSchema {
413        label: "PointX",
414        expected_type: GffType::Single,
415        required: false,
416        children: None,
417        constraint: None,
418    },
419    FieldSchema {
420        label: "PointY",
421        expected_type: GffType::Single,
422        required: false,
423        children: None,
424        constraint: None,
425    },
426    FieldSchema {
427        label: "PointZ",
428        expected_type: GffType::Single,
429        required: false,
430        children: None,
431        constraint: None,
432    },
433];
434
435impl GffSchema for Utt {
436    fn schema() -> &'static [FieldSchema] {
437        static SCHEMA: &[FieldSchema] = &[
438            // --- Identity / interaction ---
439            FieldSchema {
440                label: "Tag",
441                expected_type: GffType::String,
442                required: false,
443                children: None,
444                constraint: None,
445            },
446            FieldSchema {
447                label: "LocalizedName",
448                expected_type: GffType::LocalizedString,
449                required: false,
450                children: None,
451                constraint: None,
452            },
453            FieldSchema {
454                label: "Faction",
455                expected_type: GffType::UInt32,
456                required: false,
457                children: None,
458                constraint: None,
459            },
460            FieldSchema {
461                label: "Cursor",
462                expected_type: GffType::UInt8,
463                required: false,
464                children: None,
465                constraint: None,
466            },
467            FieldSchema {
468                label: "KeyName",
469                expected_type: GffType::String,
470                required: false,
471                children: None,
472                constraint: None,
473            },
474            FieldSchema {
475                label: "PortraitId",
476                expected_type: GffType::UInt16,
477                required: false,
478                children: None,
479                constraint: None,
480            },
481            FieldSchema {
482                label: "Portrait",
483                expected_type: GffType::ResRef,
484                required: false,
485                children: None,
486                constraint: None,
487            },
488            // --- Scripts (7) ---
489            FieldSchema {
490                label: "ScriptHeartbeat",
491                expected_type: GffType::ResRef,
492                required: false,
493                children: None,
494                constraint: None,
495            },
496            FieldSchema {
497                label: "ScriptOnEnter",
498                expected_type: GffType::ResRef,
499                required: false,
500                children: None,
501                constraint: None,
502            },
503            FieldSchema {
504                label: "ScriptOnExit",
505                expected_type: GffType::ResRef,
506                required: false,
507                children: None,
508                constraint: None,
509            },
510            FieldSchema {
511                label: "ScriptUserDefine",
512                expected_type: GffType::ResRef,
513                required: false,
514                children: None,
515                constraint: None,
516            },
517            FieldSchema {
518                label: "OnTrapTriggered",
519                expected_type: GffType::ResRef,
520                required: false,
521                children: None,
522                constraint: None,
523            },
524            FieldSchema {
525                label: "OnDisarm",
526                expected_type: GffType::ResRef,
527                required: false,
528                children: None,
529                constraint: None,
530            },
531            FieldSchema {
532                label: "OnClick",
533                expected_type: GffType::ResRef,
534                required: false,
535                children: None,
536                constraint: None,
537            },
538            // --- Trap (engine-read subset) ---
539            FieldSchema {
540                label: "TrapType",
541                expected_type: GffType::UInt8,
542                required: false,
543                children: None,
544                constraint: None,
545            },
546            FieldSchema {
547                label: "TrapOneShot",
548                expected_type: GffType::UInt8,
549                required: false,
550                children: None,
551                constraint: None,
552            },
553            FieldSchema {
554                label: "TrapDisarmable",
555                expected_type: GffType::UInt8,
556                required: false,
557                children: None,
558                constraint: None,
559            },
560            FieldSchema {
561                label: "TrapDetectable",
562                expected_type: GffType::UInt8,
563                required: false,
564                children: None,
565                constraint: None,
566            },
567            // --- Transition ---
568            FieldSchema {
569                label: "LinkedTo",
570                expected_type: GffType::String,
571                required: false,
572                children: None,
573                constraint: None,
574            },
575            FieldSchema {
576                label: "LinkedToFlags",
577                expected_type: GffType::UInt8,
578                required: false,
579                children: None,
580                constraint: None,
581            },
582            FieldSchema {
583                label: "LinkedToModule",
584                expected_type: GffType::ResRef,
585                required: false,
586                children: None,
587                constraint: None,
588            },
589            FieldSchema {
590                label: "AutoRemoveKey",
591                expected_type: GffType::UInt8,
592                required: false,
593                children: None,
594                constraint: None,
595            },
596            FieldSchema {
597                label: "TransitionDestin",
598                expected_type: GffType::LocalizedString,
599                required: false,
600                children: None,
601                constraint: None,
602            },
603            // --- Other engine-read ---
604            FieldSchema {
605                label: "Type",
606                expected_type: GffType::Int32,
607                required: false,
608                children: None,
609                constraint: None,
610            },
611            FieldSchema {
612                label: "HighlightHeight",
613                expected_type: GffType::Single,
614                required: false,
615                children: None,
616                constraint: None,
617            },
618            FieldSchema {
619                label: "LoadScreenID",
620                expected_type: GffType::UInt16,
621                required: false,
622                children: None,
623                constraint: None,
624            },
625            FieldSchema {
626                label: "SetByPlayerParty",
627                expected_type: GffType::UInt8,
628                required: false,
629                children: None,
630                constraint: None,
631            },
632            FieldSchema {
633                label: "CreatorId",
634                expected_type: GffType::UInt32,
635                required: false,
636                children: None,
637                constraint: None,
638            },
639            // --- Position / orientation (from GIT) ---
640            FieldSchema {
641                label: "XPosition",
642                expected_type: GffType::Single,
643                required: false,
644                children: None,
645                constraint: None,
646            },
647            FieldSchema {
648                label: "YPosition",
649                expected_type: GffType::Single,
650                required: false,
651                children: None,
652                constraint: None,
653            },
654            FieldSchema {
655                label: "ZPosition",
656                expected_type: GffType::Single,
657                required: false,
658                children: None,
659                constraint: None,
660            },
661            FieldSchema {
662                label: "XOrientation",
663                expected_type: GffType::Single,
664                required: false,
665                children: None,
666                constraint: None,
667            },
668            FieldSchema {
669                label: "YOrientation",
670                expected_type: GffType::Single,
671                required: false,
672                children: None,
673                constraint: None,
674            },
675            FieldSchema {
676                label: "ZOrientation",
677                expected_type: GffType::Single,
678                required: false,
679                children: None,
680                constraint: None,
681            },
682            // --- Engine-read list ---
683            FieldSchema {
684                label: "Geometry",
685                expected_type: GffType::List,
686                required: false,
687                children: Some(GEOMETRY_CHILDREN),
688                constraint: None,
689            },
690            // --- Toolset-only fields (not engine-read, but common in files) ---
691            FieldSchema {
692                label: "TemplateResRef",
693                expected_type: GffType::ResRef,
694                required: false,
695                children: None,
696                constraint: None,
697            },
698            FieldSchema {
699                label: "Comment",
700                expected_type: GffType::String,
701                required: false,
702                children: None,
703                constraint: None,
704            },
705            FieldSchema {
706                label: "PaletteID",
707                expected_type: GffType::UInt8,
708                required: false,
709                children: None,
710                constraint: None,
711            },
712            // Toolset-only trap fields (engine derives from 2DA, not GFF):
713            FieldSchema {
714                label: "TrapDetectDC",
715                expected_type: GffType::UInt8,
716                required: false,
717                children: None,
718                constraint: None,
719            },
720            FieldSchema {
721                label: "DisarmDC",
722                expected_type: GffType::UInt8,
723                required: false,
724                children: None,
725                constraint: None,
726            },
727            FieldSchema {
728                label: "TrapFlag",
729                expected_type: GffType::UInt8,
730                required: false,
731                children: None,
732                constraint: None,
733            },
734            // PartyRequired is never read by K1 engine:
735            FieldSchema {
736                label: "PartyRequired",
737                expected_type: GffType::UInt8,
738                required: false,
739                children: None,
740                constraint: None,
741            },
742        ];
743        SCHEMA
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    const TEST_UTT: &[u8] = include_bytes!(concat!(
752        env!("CARGO_MANIFEST_DIR"),
753        "/../../fixtures/test.utt"
754    ));
755    const NEWTRANSITION_UTT: &[u8] = include_bytes!(concat!(
756        env!("CARGO_MANIFEST_DIR"),
757        "/../../fixtures/newtransition9.utt"
758    ));
759
760    #[test]
761    fn reads_core_utt_fields_from_fixture() {
762        let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
763
764        assert_eq!(utt.tag, "GenericTrigger001");
765        assert_eq!(utt.template_resref, "generictrigge001");
766        assert_eq!(utt.name.string_ref.raw(), 42_968);
767        assert_eq!(utt.comment, "comment");
768        assert!(utt.auto_remove_key);
769        assert_eq!(utt.faction_id, 1);
770        assert_eq!(utt.cursor_id, 1);
771        assert_eq!(utt.highlight_height, 3.0);
772        assert_eq!(utt.key_name, "somekey");
773        assert_eq!(utt.type_id, 1);
774        assert!(utt.trap_detectable);
775        assert_eq!(utt.trap_detect_dc, 10);
776        assert!(utt.trap_disarmable);
777        assert_eq!(utt.trap_disarm_dc, 10);
778        assert!(utt.is_trap);
779        assert!(utt.trap_one_shot);
780        assert_eq!(utt.trap_type, 1);
781        assert_eq!(utt.on_disarm, "ondisarm");
782        assert_eq!(utt.on_trap_triggered, "ontraptriggered");
783        assert_eq!(utt.on_click, "onclick");
784        assert_eq!(utt.on_heartbeat, "onheartbeat");
785        assert_eq!(utt.on_enter, "onenter");
786        assert_eq!(utt.on_exit, "onexit");
787        assert_eq!(utt.on_user_defined, "onuserdefined");
788        assert_eq!(utt.palette_id, 6);
789        assert_eq!(utt.portrait_id, 0);
790        assert_eq!(utt.loadscreen_id, 0);
791    }
792
793    #[test]
794    fn reads_transition_fixture_variant() {
795        let utt = read_utt_from_bytes(NEWTRANSITION_UTT).expect("fixture must parse");
796
797        assert_eq!(utt.tag, "AreaTransition");
798        assert_eq!(utt.template_resref, "newtransition9");
799        assert_eq!(utt.name.string_ref.raw(), -1);
800        assert_eq!(utt.cursor_id, 1);
801        assert_eq!(utt.faction_id, 1);
802        assert_eq!(utt.type_id, 1);
803        assert!(!utt.auto_remove_key);
804        assert!(!utt.is_trap);
805        assert_eq!(utt.on_enter, "ebon_11");
806        assert_eq!(utt.palette_id, 5);
807        assert_eq!(utt.linked_to, "");
808        assert_eq!(utt.linked_to_flags, 0);
809        assert!(!utt.party_required);
810    }
811
812    #[test]
813    fn all_fields_survive_typed_roundtrip() {
814        let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
815        let bytes = write_utt_to_vec(&utt).expect("write succeeds");
816        let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
817
818        assert_eq!(reparsed, utt);
819    }
820
821    #[test]
822    fn typed_edits_roundtrip_through_gff_writer() {
823        let mut utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
824        utt.tag = "GenericTrigger001_Rust".into();
825        utt.on_enter = ResRef::new("rust_on_enter").expect("valid test resref");
826        utt.is_trap = false;
827
828        let bytes = write_utt_to_vec(&utt).expect("write succeeds");
829        let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
830
831        assert_eq!(reparsed.tag, "GenericTrigger001_Rust");
832        assert_eq!(reparsed.on_enter, "rust_on_enter");
833        assert!(!reparsed.is_trap);
834    }
835
836    #[test]
837    fn read_utt_from_reader_matches_bytes_path() {
838        let mut cursor = Cursor::new(TEST_UTT);
839        let via_reader = read_utt(&mut cursor).expect("reader parse succeeds");
840        let via_bytes = read_utt_from_bytes(TEST_UTT).expect("bytes parse succeeds");
841
842        assert_eq!(via_reader, via_bytes);
843    }
844
845    #[test]
846    fn rejects_non_utt_file_type() {
847        let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
848        gff.file_type = *b"UTD ";
849
850        let err = Utt::from_gff(&gff).expect_err("UTD must be rejected as UTT input");
851        assert!(matches!(
852            err,
853            UttError::UnsupportedFileType(file_type) if file_type == *b"UTD "
854        ));
855    }
856
857    #[test]
858    fn type_mismatch_on_localized_name_is_error() {
859        let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
860        gff.root
861            .fields
862            .retain(|field| field.label != "LocalizedName");
863        gff.root.push_field("LocalizedName", GffValue::UInt32(5));
864
865        let err = Utt::from_gff(&gff).expect_err("type mismatch must be rejected");
866        assert!(matches!(
867            err,
868            UttError::TypeMismatch {
869                field: "LocalizedName",
870                expected: "LocalizedString",
871            }
872        ));
873    }
874
875    #[test]
876    fn write_utt_matches_direct_gff_writer() {
877        let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
878
879        let via_typed = write_utt_to_vec(&utt).expect("typed write succeeds");
880
881        let mut direct = Cursor::new(Vec::new());
882        write_gff(&mut direct, &utt.to_gff()).expect("direct write succeeds");
883
884        assert_eq!(via_typed, direct.into_inner());
885    }
886
887    #[test]
888    fn schema_field_count() {
889        assert_eq!(Utt::schema().len(), 42);
890    }
891
892    #[test]
893    fn schema_no_duplicate_labels() {
894        let schema = Utt::schema();
895        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
896        labels.sort();
897        let before = labels.len();
898        labels.dedup();
899        assert_eq!(before, labels.len(), "duplicate labels in UTT schema");
900    }
901
902    #[test]
903    fn schema_geometry_has_children() {
904        let geometry = Utt::schema()
905            .iter()
906            .find(|f| f.label == "Geometry")
907            .expect("test fixture must be valid");
908        assert!(geometry.children.is_some());
909        assert_eq!(
910            geometry.children.expect("test fixture must be valid").len(),
911            3
912        );
913    }
914}