Skip to main content

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