Skip to main content

rakata_generics/
utt.rs

1//! UTT (`.utt`) typed generic wrapper.
2//!
3//! Triggers are invisible zones that act as tripwires, firing scripts, serving
4//! as loading zones into another area, or springing traps when a character
5//! crosses them.
6//!
7//! ## Field Layout (simplified)
8//! ```text
9//! UTT root struct
10//! +-- TemplateResRef / Tag / LocalizedName / Comment
11//! +-- Cursor / Type / Faction / HighlightHeight
12//! +-- AutoRemoveKey / KeyName
13//! +-- TrapDetectable / TrapDetectDC / TrapDisarmable / DisarmDC / TrapFlag / TrapOneShot / TrapType
14//! +-- Script hooks (OnDisarm/OnTrapTriggered/OnClick/ScriptHeartbeat/ScriptOnEnter/ScriptOnExit/ScriptUserDefine)
15//! +-- LinkedTo / LinkedToFlags / LinkedToModule / PartyRequired / SetByPlayerParty
16//! +-- TransitionDestin (localized destination text)
17//! +-- PortraitId / Portrait / LoadScreenID / PaletteID
18//! ```
19
20use std::io::{Cursor, Read, Write};
21
22use rakata_core::ResRef;
23use rakata_formats::schema::FromGff;
24use rakata_formats::GENERIC_FILE_TYPE;
25use rakata_formats::{
26    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
27    GffStruct,
28};
29use thiserror::Error;
30
31/// Typed UTT model built from/to [`Gff`] data.
32#[derive(Debug, Clone, PartialEq, GffModel)]
33#[gff_entry(CreatorId, wire = u32, stamped = 2130706432)]
34#[gff_entry(XPosition, wire = f32, not_a_constant)]
35#[gff_entry(YPosition, wire = f32, not_a_constant)]
36#[gff_entry(ZPosition, wire = f32, not_a_constant)]
37#[gff_entry(XOrientation, wire = f32, not_a_constant)]
38#[gff_entry(YOrientation, wire = f32, not_a_constant)]
39#[gff_entry(ZOrientation, wire = f32, not_a_constant)]
40pub struct Utt {
41    /// Trigger tag (`Tag`).
42    #[gff(Tag, constructed)]
43    pub tag: String,
44    /// Localized trigger name (`LocalizedName`).
45    #[gff(LocalizedName, stamped)]
46    pub name: GffLocalizedString,
47    /// Faction identifier (`Faction`).
48    #[gff(Faction, constructed)]
49    pub faction_id: u32,
50    /// Cursor identifier (`Cursor`).
51    #[gff(Cursor, stamped)]
52    pub cursor_id: u8,
53    /// Key name (`KeyName`).
54    #[gff(KeyName, stamped)]
55    pub key_name: String,
56    /// Portrait ID (`PortraitId`). If `< 0xFFFE`, the engine completely shadows and ignores the string `Portrait` resref field.
57    #[gff(PortraitId, stamped = 65535)]
58    pub portrait_id: u16,
59    /// Portrait resref (`Portrait`). Ignored by the engine if `portrait_id` is `< 0xFFFE`.
60    #[gff(Portrait, stamped)]
61    pub portrait: ResRef,
62    /// On-heartbeat script (`ScriptHeartbeat`).
63    #[gff(ScriptHeartbeat, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
64    pub on_heartbeat: ResRef,
65    /// On-enter script (`ScriptOnEnter`).
66    #[gff(ScriptOnEnter, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
67    pub on_enter: ResRef,
68    /// On-exit script (`ScriptOnExit`).
69    #[gff(ScriptOnExit, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
70    pub on_exit: ResRef,
71    /// On-user-defined script (`ScriptUserDefine`).
72    #[gff(ScriptUserDefine, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
73    pub on_user_defined: ResRef,
74    /// On-trap-triggered script (`OnTrapTriggered`).
75    #[gff(OnTrapTriggered, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
76    pub on_trap_triggered: ResRef,
77    /// On-disarm script (`OnDisarm`).
78    #[gff(OnDisarm, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
79    pub on_disarm: ResRef,
80    /// On-click script (`OnClick`). Only fires for Transition triggers (Type 1). Ignored on Generic triggers.
81    #[gff(OnClick, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
82    pub on_click: ResRef,
83    /// Trap type (`TrapType`).
84    #[gff(TrapType, constructed = 255)]
85    pub trap_type: u8,
86    /// Trap one-shot flag (`TrapOneShot`).
87    #[gff(TrapOneShot, constructed = true)]
88    pub trap_one_shot: bool,
89    /// Trap-disarmable flag (`TrapDisarmable`).
90    #[gff(TrapDisarmable, stamped)]
91    pub trap_disarmable: bool,
92    /// Trap-detectable flag (`TrapDetectable`).
93    #[gff(TrapDetectable, stamped)]
94    pub trap_detectable: bool,
95    /// Linked target tag (`LinkedTo`).
96    #[gff(
97        LinkedTo,
98        read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
99        unexamined
100    )]
101    pub linked_to: String,
102    /// Linked target flags (`LinkedToFlags`).
103    #[gff(
104        LinkedToFlags,
105        read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
106        unexamined
107    )]
108    pub linked_to_flags: u8,
109    /// Linked target module resref (`LinkedToModule`).
110    #[gff(
111        LinkedToModule,
112        read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
113        unexamined
114    )]
115    pub linked_to_module: ResRef,
116    /// Auto-remove-key flag (`AutoRemoveKey`).
117    #[gff(AutoRemoveKey, constructed)]
118    pub auto_remove_key: bool,
119    /// Localized transition destination text (`TransitionDestin`).
120    #[gff(
121        TransitionDestin,
122        read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
123        unexamined
124    )]
125    pub transition_destination: GffLocalizedString,
126    /// Trigger type id (`Type`). Type 1 (Transition) requires a linked destination. Type 2 (Trap) is required for trap flags to function.
127    #[gff(Type, stamped)]
128    pub type_id: i32,
129    /// Highlight height (`HighlightHeight`). Values <= 0.0 are ignored and default to 0.1.
130    #[gff(HighlightHeight, unexamined)]
131    pub highlight_height: f32,
132    /// Loadscreen ID (`LoadScreenID`).
133    #[gff(LoadScreenID, stamped)]
134    pub loadscreen_id: u16,
135    /// Set-by-player-party flag (`SetByPlayerParty`).
136    #[gff(SetByPlayerParty, stamped, omit = audited_constant(1121))]
137    pub set_by_player_party: bool,
138    /// Geometry polygon vertices (`Geometry`). Engine requires at least 3 vertices to form a valid polygon.
139    #[gff(Geometry, not_a_constant, omit = empty(1121), list = UttGeometryPoint, element_id = 0)]
140    pub geometry: Vec<UttGeometryPoint>,
141    /// Trigger template resref (`TemplateResRef`).
142    #[gff(TemplateResRef, unexamined)]
143    pub template_resref: ResRef,
144    /// Toolset comment (`Comment`).
145    #[gff(Comment, unexamined)]
146    pub comment: String,
147    /// Palette ID (`PaletteID`).
148    #[gff(PaletteID, unexamined)]
149    pub palette_id: u8,
150    /// Trap detect DC (`TrapDetectDC`). Ignored by the engine; trap DCs are derived dynamically from traps.2da.
151    #[gff(TrapDetectDC, stamped)]
152    pub trap_detect_dc: u8,
153    /// Trap disarm DC (`DisarmDC`). Ignored by the engine; trap DCs are derived dynamically from traps.2da.
154    #[gff(DisarmDC, stamped)]
155    pub trap_disarm_dc: u8,
156    /// Trap enabled flag (`TrapFlag`). Engine ignores this if `type_id` is not 2 (Trap).
157    #[gff(TrapFlag, stamped)]
158    pub is_trap: bool,
159    /// Party-required flag (`PartyRequired`). Legacy NWN data never read by the K1 engine.
160    #[gff(
161        PartyRequired,
162        read_only_dead = "read by nothing on the trigger path",
163        not_a_constant
164    )]
165    pub party_required: bool,
166}
167
168/// One vertex of a trigger's `Geometry`.
169///
170/// A copy of the shared block while the two vocabularies coexist. All three
171/// labels are recorded identically for `Utt` and `Git` -- type, required,
172/// constraint, omission, liveness, absent-value and substitute -- so the two
173/// collapse back into one type when GIT moves. `Ute`'s geometry is a
174/// different three labels and shares nothing with either.
175#[derive(Debug, Clone, PartialEq, GffModel)]
176pub struct UttGeometryPoint {
177    /// X coordinate (`PointX`).
178    #[gff(PointX, unexamined)]
179    pub point_x: f32,
180    /// Y coordinate (`PointY`).
181    #[gff(PointY, unexamined)]
182    pub point_y: f32,
183    /// Z coordinate (`PointZ`).
184    #[gff(PointZ, unexamined)]
185    pub point_z: f32,
186}
187
188impl Utt {
189    /// Creates an empty UTT value.
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Builds typed UTT data from a parsed GFF container.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`UttError::UnsupportedFileType`] for a container that is
199    /// neither `UTT ` nor the generic `GFF ` form.
200    pub fn from_gff(gff: &Gff) -> Result<Self, UttError> {
201        if gff.file_type != <Utt as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
202            return Err(UttError::UnsupportedFileType(gff.file_type));
203        }
204
205        Ok(Self::read_declared(&gff.root))
206    }
207
208    /// Converts this typed UTT value into a GFF container.
209    pub fn to_gff(&self) -> Gff {
210        let mut root = GffStruct::new(-1);
211        self.write_declared(&mut root);
212        Gff::new(*b"UTT ", root)
213    }
214}
215
216/// Errors produced while reading or writing typed UTT data.
217#[derive(Debug, Error)]
218pub enum UttError {
219    /// Source file type is not supported by this parser.
220    #[error("unsupported UTT file type: {0:?}")]
221    UnsupportedFileType([u8; 4]),
222    /// Underlying GFF parser/writer error.
223    #[error(transparent)]
224    Gff(#[from] GffBinaryError),
225}
226
227/// Reads typed UTT data from a reader at the current stream position.
228///
229/// # Errors
230///
231/// [`UttError::Gff`] when the stream is not a readable GFF, and
232/// [`UttError::UnsupportedFileType`] when it is a GFF of some other format,
233/// carrying the fourcc that was found.
234#[cfg_attr(
235    feature = "tracing",
236    tracing::instrument(level = "debug", skip(reader))
237)]
238pub fn read_utt<R: Read>(reader: &mut R) -> Result<Utt, UttError> {
239    let gff = read_gff(reader)?;
240    Utt::from_gff(&gff)
241}
242
243/// Reads typed UTT data directly from bytes.
244///
245/// # Errors
246///
247/// [`UttError::Gff`] when `bytes` are not a readable GFF, and
248/// [`UttError::UnsupportedFileType`] when they are a GFF of some other format,
249/// carrying the fourcc that was found.
250#[cfg_attr(
251    feature = "tracing",
252    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
253)]
254pub fn read_utt_from_bytes(bytes: &[u8]) -> Result<Utt, UttError> {
255    let gff = read_gff_from_bytes(bytes)?;
256    Utt::from_gff(&gff)
257}
258
259/// Authors the UTT file the typed view describes, into a writer.
260///
261/// # Errors
262///
263/// [`UttError::Gff`] when the writer fails or a value will not encode. The
264/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
265/// this side.
266#[cfg_attr(
267    feature = "tracing",
268    tracing::instrument(level = "debug", skip(writer, utt))
269)]
270pub fn author_utt<W: Write>(writer: &mut W, utt: &Utt) -> Result<(), UttError> {
271    let gff = utt.to_gff();
272    write_gff(writer, &gff)?;
273    Ok(())
274}
275
276/// Authors the UTT file the typed view describes, as bytes.
277///
278/// # Errors
279///
280/// [`UttError::Gff`] when a value will not encode. Writing into a `Vec` has no
281/// I/O to fail at.
282#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utt)))]
283pub fn author_utt_to_vec(utt: &Utt) -> Result<Vec<u8>, UttError> {
284    let mut cursor = Cursor::new(Vec::new());
285    author_utt(&mut cursor, utt)?;
286    Ok(cursor.into_inner())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use rakata_formats::schema::{HasSchema, Shape};
293    use rakata_formats::{gff_label, GffValue};
294
295    const TEST_UTT: &[u8] = include_bytes!(concat!(
296        env!("CARGO_MANIFEST_DIR"),
297        "/../../fixtures/test.utt"
298    ));
299    const NEWTRANSITION_UTT: &[u8] = include_bytes!(concat!(
300        env!("CARGO_MANIFEST_DIR"),
301        "/../../fixtures/newtransition9.utt"
302    ));
303
304    #[test]
305    fn reads_core_utt_fields_from_fixture() {
306        let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
307
308        assert_eq!(utt.tag, "GenericTrigger001");
309        assert_eq!(utt.template_resref, "generictrigge001");
310        assert_eq!(utt.name.string_ref.raw(), 42_968);
311        assert_eq!(utt.comment, "comment");
312        assert!(utt.auto_remove_key);
313        assert_eq!(utt.faction_id, 1);
314        assert_eq!(utt.cursor_id, 1);
315        assert_eq!(utt.highlight_height, 3.0);
316        assert_eq!(utt.key_name, "somekey");
317        assert_eq!(utt.type_id, 1);
318        assert!(utt.trap_detectable);
319        assert_eq!(utt.trap_detect_dc, 10);
320        assert!(utt.trap_disarmable);
321        assert_eq!(utt.trap_disarm_dc, 10);
322        assert!(utt.is_trap);
323        assert!(utt.trap_one_shot);
324        assert_eq!(utt.trap_type, 1);
325        assert_eq!(utt.on_disarm, "ondisarm");
326        assert_eq!(utt.on_trap_triggered, "ontraptriggered");
327        assert_eq!(utt.on_click, "onclick");
328        assert_eq!(utt.on_heartbeat, "onheartbeat");
329        assert_eq!(utt.on_enter, "onenter");
330        assert_eq!(utt.on_exit, "onexit");
331        assert_eq!(utt.on_user_defined, "onuserdefined");
332        assert_eq!(utt.palette_id, 6);
333        assert_eq!(utt.portrait_id, 0);
334        assert_eq!(utt.loadscreen_id, 0);
335    }
336
337    #[test]
338    fn reads_transition_fixture_variant() {
339        let utt = read_utt_from_bytes(NEWTRANSITION_UTT).expect("fixture must parse");
340
341        assert_eq!(utt.tag, "AreaTransition");
342        assert_eq!(utt.template_resref, "newtransition9");
343        assert_eq!(utt.name.string_ref.raw(), -1);
344        assert_eq!(utt.cursor_id, 1);
345        assert_eq!(utt.faction_id, 1);
346        assert_eq!(utt.type_id, 1);
347        assert!(!utt.auto_remove_key);
348        assert!(!utt.is_trap);
349        assert_eq!(utt.on_enter, "ebon_11");
350        assert_eq!(utt.palette_id, 5);
351        assert_eq!(utt.linked_to, "");
352        assert_eq!(utt.linked_to_flags, 0);
353        assert!(!utt.party_required);
354    }
355
356    #[test]
357    fn all_fields_survive_typed_roundtrip() {
358        let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
359        let bytes = author_utt_to_vec(&utt).expect("write succeeds");
360        let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
361
362        assert_eq!(reparsed, utt);
363    }
364
365    #[test]
366    fn typed_edits_roundtrip_through_gff_writer() {
367        let mut utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
368        utt.tag = "GenericTrigger001_Rust".into();
369        utt.on_enter = ResRef::new("rust_on_enter").expect("valid test resref");
370        utt.is_trap = false;
371
372        let bytes = author_utt_to_vec(&utt).expect("write succeeds");
373        let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
374
375        assert_eq!(reparsed.tag, "GenericTrigger001_Rust");
376        assert_eq!(reparsed.on_enter, "rust_on_enter");
377        assert!(!reparsed.is_trap);
378    }
379
380    #[test]
381    fn read_utt_from_reader_matches_bytes_path() {
382        let mut cursor = Cursor::new(TEST_UTT);
383        let via_reader = read_utt(&mut cursor).expect("reader parse succeeds");
384        let via_bytes = read_utt_from_bytes(TEST_UTT).expect("bytes parse succeeds");
385
386        assert_eq!(via_reader, via_bytes);
387    }
388
389    #[test]
390    fn rejects_non_utt_file_type() {
391        let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
392        gff.file_type = *b"UTD ";
393
394        let err = Utt::from_gff(&gff).expect_err("UTD must be rejected as UTT input");
395        assert!(matches!(
396            err,
397            UttError::UnsupportedFileType(file_type) if file_type == *b"UTD "
398        ));
399    }
400
401    #[test]
402    fn a_mistyped_localized_name_reads_as_absent() {
403        let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
404        gff.root
405            .fields
406            .retain(|field| field.label != "LocalizedName");
407        gff.root
408            .push_field(gff_label!("LocalizedName"), GffValue::UInt32(5));
409
410        let utt = Utt::from_gff(&gff).expect("a mistyped field is not a read failure");
411
412        assert_eq!(utt.name, GffLocalizedString::default());
413    }
414
415    #[test]
416    fn write_utt_matches_direct_gff_writer() {
417        let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
418
419        let via_typed = author_utt_to_vec(&utt).expect("typed write succeeds");
420
421        let mut direct = Cursor::new(Vec::new());
422        write_gff(&mut direct, &utt.to_gff()).expect("direct write succeeds");
423
424        assert_eq!(via_typed, direct.into_inner());
425    }
426
427    #[test]
428    fn schema_field_count() {
429        assert_eq!(Utt::schema().len(), 42);
430    }
431
432    #[test]
433    fn schema_no_duplicate_labels() {
434        let mut labels: Vec<&str> = Utt::schema().iter().map(|f| f.label.as_str()).collect();
435        labels.sort_unstable();
436        let before = labels.len();
437        labels.dedup();
438        assert_eq!(before, labels.len(), "duplicate labels in UTT schema");
439    }
440
441    #[test]
442    fn schema_geometry_carries_its_element() {
443        let geometry = Utt::schema()
444            .iter()
445            .find(|f| f.label.as_str() == "Geometry")
446            .expect("Geometry is declared");
447        let Shape::List { element, .. } = geometry.shape else {
448            panic!("Geometry is a list");
449        };
450        assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 3);
451    }
452}