Skip to main content

rakata_generics/
ute.rs

1//! UTE (`.ute`) typed generic wrapper.
2//!
3//! UTE resources are GFF-backed encounter templates that define what creatures
4//! spawn, how many, and under what conditions.
5//!
6//! ## Scope
7//! - Typed access for encounter identity, spawn config, difficulty, and scripts.
8//! - Typed creature list (`CreatureList`) with per-entry template, CR, and
9//!   single-spawn flag.
10//! - Position, runtime state, area tracking, geometry, spawn points, area list,
11//!   and spawn list are all fully modeled.
12//!
13//! ## Field Layout
14//! ```text
15//! UTE root struct
16//! +-- TemplateResRef / Tag / LocalizedName / Comment / PaletteID
17//! +-- Active / Reset / ResetTime / Respawns / SpawnOption
18//! +-- MaxCreatures / RecCreatures / PlayerOnly / Faction
19//! +-- DifficultyIndex / Difficulty
20//! +-- XPosition / YPosition / ZPosition
21//! +-- OnEntered / OnExit / OnHeartbeat / OnExhausted / OnUserDefined
22//! +-- NumberSpawned / HeartbeatDay / HeartbeatTime / LastSpawnDay
23//! +-- LastSpawnTime / LastEntered / LastLeft / Started / Exhausted
24//! +-- CurrentSpawns / CustomScriptId
25//! +-- AreaListMaxSize / SpawnPoolActive / AreaPoints
26//! +-- CreatureList[]
27//! |   +-- ResRef / CR / SingleSpawn
28//! +-- Geometry[]
29//! |   +-- X / Y / Z
30//! +-- SpawnPointList[]
31//! |   +-- X / Y / Z / Orientation
32//! +-- AreaList[]
33//! |   +-- AreaObject
34//! +-- SpawnList[]
35//!     +-- SpawnResRef / SpawnCR
36//! ```
37
38use std::io::{Cursor, Read, Write};
39
40use rakata_core::ResRef;
41use rakata_formats::gff::upsert_field;
42use rakata_formats::gff_label;
43use rakata_formats::schema::FromGff;
44use rakata_formats::GENERIC_FILE_TYPE;
45use rakata_formats::{
46    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
47    GffStruct, GffValue,
48};
49use thiserror::Error;
50
51/// Typed UTE model built from/to [`Gff`] data.
52#[derive(Debug, Clone, PartialEq, GffModel)]
53pub struct Ute {
54    /// Encounter template resref (`TemplateResRef`). Legacy artifact completely ignored by the engine.
55    #[gff(TemplateResRef, unexamined)]
56    pub template_resref: ResRef,
57    /// Encounter tag (`Tag`).
58    #[gff(Tag, unanswered)]
59    pub tag: String,
60    /// Localized encounter name (`LocalizedName`).
61    #[gff(LocalizedName, constructed)]
62    pub name: GffLocalizedString,
63    /// Toolset comment (`Comment`). Legacy artifact completely ignored by the engine.
64    #[gff(Comment, unexamined)]
65    pub comment: String,
66    /// Palette ID (`PaletteID`). Legacy artifact completely ignored by the engine.
67    #[gff(PaletteID, unexamined)]
68    pub palette_id: u8,
69    /// Whether the encounter is active (`Active`). Encounter must contain at least one creature if active.
70    #[gff(Active, constructed = true)]
71    pub active: bool,
72    /// Whether the encounter resets (`Reset`).
73    #[gff(Reset, constructed)]
74    pub reset: bool,
75    /// Reset time in seconds (`ResetTime`).
76    #[gff(ResetTime, constructed = 60)]
77    pub reset_time: i32,
78    /// Number of respawns (`Respawns`).
79    #[gff(Respawns, constructed)]
80    pub respawns: i32,
81    /// Spawn option flag (`SpawnOption`).
82    #[gff(SpawnOption, constructed)]
83    pub spawn_option: i32,
84    /// Maximum creatures (`MaxCreatures`).
85    #[gff(MaxCreatures, constructed = 8)]
86    pub max_creatures: i32,
87    /// Recommended creatures (`RecCreatures`).
88    #[gff(RecCreatures, constructed = 2)]
89    pub rec_creatures: i32,
90    /// Player-only flag (`PlayerOnly`).
91    #[gff(PlayerOnly, constructed)]
92    pub player_only: bool,
93    /// Faction identifier (`Faction`).
94    #[gff(Faction, constructed = 1)]
95    pub faction_id: u32,
96    /// Difficulty index (`DifficultyIndex`).
97    #[gff(DifficultyIndex, unanswered)]
98    pub difficulty_index: i32,
99    /// Difficulty value (`Difficulty`). Ignored by the engine in favor of `difficulty_index` when statically defined.
100    #[gff(Difficulty, unanswered)]
101    pub difficulty: i32,
102    /// X position in area (`XPosition`).
103    #[gff(XPosition, constructed, omit = audited_constant(133))]
104    pub x_position: f32,
105    /// Y position in area (`YPosition`).
106    #[gff(YPosition, constructed, omit = audited_constant(133))]
107    pub y_position: f32,
108    /// Z position in area (`ZPosition`).
109    #[gff(ZPosition, constructed, omit = audited_constant(133))]
110    pub z_position: f32,
111    /// On-entered script (`OnEntered`).
112    #[gff(OnEntered, constructed)]
113    pub on_entered: ResRef,
114    /// On-exit script (`OnExit`).
115    #[gff(OnExit, constructed)]
116    pub on_exit: ResRef,
117    /// On-heartbeat script (`OnHeartbeat`).
118    #[gff(OnHeartbeat, constructed)]
119    pub on_heartbeat: ResRef,
120    /// On-exhausted script (`OnExhausted`).
121    #[gff(OnExhausted, constructed)]
122    pub on_exhausted: ResRef,
123    /// On-user-defined script (`OnUserDefined`).
124    #[gff(OnUserDefined, constructed)]
125    pub on_user_defined: ResRef,
126    /// Number of creatures spawned so far (`NumberSpawned`).
127    #[gff(NumberSpawned, constructed, omit = audited_constant(133))]
128    pub number_spawned: i32,
129    /// Heartbeat day counter (`HeartbeatDay`).
130    #[gff(HeartbeatDay, constructed, omit = audited_constant(133))]
131    pub heartbeat_day: u32,
132    /// Heartbeat time counter (`HeartbeatTime`).
133    #[gff(HeartbeatTime, constructed, omit = audited_constant(133))]
134    pub heartbeat_time: u32,
135    /// Day of last spawn (`LastSpawnDay`).
136    #[gff(LastSpawnDay, constructed, omit = audited_constant(133))]
137    pub last_spawn_day: u32,
138    /// Time of last spawn (`LastSpawnTime`).
139    #[gff(LastSpawnTime, constructed, omit = audited_constant(133))]
140    pub last_spawn_time: u32,
141    /// Last-entered object ID (`LastEntered`).
142    #[gff(LastEntered, constructed = 2130706432, omit = audited_constant(133))]
143    pub last_entered: u32,
144    /// Last-left object ID (`LastLeft`).
145    #[gff(LastLeft, constructed = 2130706432, omit = audited_constant(133))]
146    pub last_left: u32,
147    /// Whether the encounter has started (`Started`).
148    #[gff(Started, constructed, omit = audited_constant(133))]
149    pub started: bool,
150    /// Whether the encounter is exhausted (`Exhausted`).
151    #[gff(Exhausted, constructed, omit = audited_constant(133))]
152    pub exhausted: bool,
153    /// Current live spawn count (`CurrentSpawns`).
154    #[gff(CurrentSpawns, constructed, omit = audited_constant(133))]
155    pub current_spawns: i32,
156    /// Custom script identifier (`CustomScriptId`).
157    #[gff(CustomScriptId, undefined)]
158    pub custom_script_id: i32,
159    /// Maximum size of the area list (`AreaListMaxSize`).
160    #[gff(AreaListMaxSize, constructed = 16, omit = audited_constant(133))]
161    pub area_list_max_size: i32,
162    /// Active spawn pool value (`SpawnPoolActive`).
163    #[gff(SpawnPoolActive, constructed, omit = audited_constant(133))]
164    pub spawn_pool_active: f32,
165    /// Area points value (`AreaPoints`).
166    #[gff(AreaPoints, undefined)]
167    pub area_points: f32,
168    /// Creatures that can spawn in this encounter (`CreatureList`).
169    #[gff(CreatureList, not_a_constant, list = UteCreature, element_id = 0)]
170    pub creatures: Vec<UteCreature>,
171    /// Boundary geometry vertices (`Geometry`). If an empty list is explicitly present in the data, the engine will crash on load.
172    #[gff(Geometry, manual_write, not_a_constant, list = UteGeometryVertex, element_id = 0)]
173    pub geometry: Vec<UteGeometryVertex>,
174    /// Spawn point positions (`SpawnPointList`).
175    #[gff(SpawnPointList, not_a_constant, omit = empty(133), list = UteSpawnPoint, element_id = 0)]
176    pub spawn_points: Vec<UteSpawnPoint>,
177    /// Area tracking entries (`AreaList`).
178    #[gff(AreaList, not_a_constant, omit = empty(133), list = UteAreaEntry, element_id = 0)]
179    pub area_list: Vec<UteAreaEntry>,
180    /// Spawn resource entries (`SpawnList`).
181    #[gff(SpawnList, not_a_constant, omit = empty(133), list = UteSpawnEntry, element_id = 0)]
182    pub spawn_list: Vec<UteSpawnEntry>,
183}
184
185impl Ute {
186    /// Creates an empty UTE value.
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    /// Builds typed UTE data from a parsed GFF container.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`UteError::UnsupportedFileType`] for a container that is
196    /// neither `UTE ` nor the generic `GFF ` form.
197    pub fn from_gff(gff: &Gff) -> Result<Self, UteError> {
198        if gff.file_type != <Ute as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
199            return Err(UteError::UnsupportedFileType(gff.file_type));
200        }
201
202        Ok(Self::read_declared(&gff.root))
203    }
204
205    /// Converts this typed UTE value into a GFF container.
206    pub fn to_gff(&self) -> Gff {
207        let mut root = GffStruct::new(-1);
208        self.write_declared(&mut root);
209
210        // An empty `Geometry` present in the file crashes the engine on load,
211        // so it is left out entirely rather than written empty. That is a
212        // property of the reader rather than of the engine's own writer, which
213        // is why it is here and not an omission on the entry.
214        if !self.geometry.is_empty() {
215            upsert_field(
216                &mut root,
217                gff_label!("Geometry"),
218                GffValue::List(
219                    self.geometry
220                        .iter()
221                        .map(|vertex| {
222                            let mut element = GffStruct::new(0);
223                            vertex.write_element(&mut element);
224                            element
225                        })
226                        .collect(),
227                ),
228            );
229        }
230
231        Gff::new(*b"UTE ", root)
232    }
233}
234
235/// Typed UTE one entry of `CreatureList`.
236#[derive(Debug, Clone, PartialEq, GffModel)]
237pub struct UteCreature {
238    /// Creature template resref (`ResRef`).
239    #[gff(ResRef, unexamined)]
240    pub resref: ResRef,
241    /// Challenge rating (`CR`).
242    #[gff(CR, unexamined)]
243    pub cr: f32,
244    /// Single-spawn flag (`SingleSpawn`).
245    #[gff(SingleSpawn, unexamined)]
246    pub single_spawn: bool,
247}
248
249/// Typed UTE one vertex of `Geometry`.
250#[derive(Debug, Clone, PartialEq, GffModel)]
251pub struct UteGeometryVertex {
252    /// X coordinate.
253    #[gff(X, unexamined)]
254    pub x: f32,
255    /// Y coordinate.
256    #[gff(Y, unexamined)]
257    pub y: f32,
258    /// Z coordinate.
259    #[gff(Z, unexamined)]
260    pub z: f32,
261}
262
263/// Typed UTE one entry of `SpawnPointList`.
264#[derive(Debug, Clone, PartialEq, GffModel)]
265pub struct UteSpawnPoint {
266    /// X coordinate.
267    #[gff(X, unexamined)]
268    pub x: f32,
269    /// Y coordinate.
270    #[gff(Y, unexamined)]
271    pub y: f32,
272    /// Z coordinate.
273    #[gff(Z, unexamined)]
274    pub z: f32,
275    /// Facing orientation in radians.
276    #[gff(Orientation, unexamined)]
277    pub orientation: f32,
278}
279
280/// Typed UTE one entry of `AreaList`.
281#[derive(Debug, Clone, PartialEq, GffModel)]
282pub struct UteAreaEntry {
283    /// Area object identifier (`AreaObject`).
284    #[gff(AreaObject, unexamined)]
285    pub area_object: u32,
286}
287
288/// Typed UTE one entry of `SpawnList`.
289#[derive(Debug, Clone, PartialEq, GffModel)]
290pub struct UteSpawnEntry {
291    /// Spawn creature resref (`SpawnResRef`).
292    #[gff(SpawnResRef, unexamined)]
293    pub spawn_resref: ResRef,
294    /// Spawn challenge rating (`SpawnCR`).
295    #[gff(SpawnCR, unexamined)]
296    pub spawn_cr: f32,
297}
298
299/// Errors produced while reading or writing typed UTE data.
300#[derive(Debug, Error)]
301pub enum UteError {
302    /// Source file type is not supported by this parser.
303    #[error("unsupported UTE file type: {0:?}")]
304    UnsupportedFileType([u8; 4]),
305    /// Underlying GFF parser/writer error.
306    #[error(transparent)]
307    Gff(#[from] GffBinaryError),
308}
309
310/// Reads typed UTE data from a reader at the current stream position.
311///
312/// # Errors
313///
314/// [`UteError::Gff`] when the stream is not a readable GFF, and
315/// [`UteError::UnsupportedFileType`] when it is a GFF of some other format,
316/// carrying the fourcc that was found.
317#[cfg_attr(
318    feature = "tracing",
319    tracing::instrument(level = "debug", skip(reader))
320)]
321pub fn read_ute<R: Read>(reader: &mut R) -> Result<Ute, UteError> {
322    let gff = read_gff(reader)?;
323    Ute::from_gff(&gff)
324}
325
326/// Reads typed UTE data directly from bytes.
327///
328/// # Errors
329///
330/// [`UteError::Gff`] when `bytes` are not a readable GFF, and
331/// [`UteError::UnsupportedFileType`] when they are a GFF of some other format,
332/// carrying the fourcc that was found.
333#[cfg_attr(
334    feature = "tracing",
335    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
336)]
337pub fn read_ute_from_bytes(bytes: &[u8]) -> Result<Ute, UteError> {
338    let gff = read_gff_from_bytes(bytes)?;
339    Ute::from_gff(&gff)
340}
341
342/// Authors the UTE file the typed view describes, into a writer.
343///
344/// # Errors
345///
346/// [`UteError::Gff`] when the writer fails or a value will not encode. The
347/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
348/// this side.
349#[cfg_attr(
350    feature = "tracing",
351    tracing::instrument(level = "debug", skip(writer, ute))
352)]
353pub fn author_ute<W: Write>(writer: &mut W, ute: &Ute) -> Result<(), UteError> {
354    let gff = ute.to_gff();
355    write_gff(writer, &gff)?;
356    Ok(())
357}
358
359/// Authors the UTE file the typed view describes, as bytes.
360///
361/// # Errors
362///
363/// [`UteError::Gff`] when a value will not encode. Writing into a `Vec` has no
364/// I/O to fail at.
365#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(ute)))]
366pub fn author_ute_to_vec(ute: &Ute) -> Result<Vec<u8>, UteError> {
367    let mut cursor = Cursor::new(Vec::new());
368    author_ute(&mut cursor, ute)?;
369    Ok(cursor.into_inner())
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use rakata_core::StrRef;
376    use rakata_formats::schema::{HasSchema, Shape};
377
378    #[test]
379    fn empty_geometry_is_omitted_and_a_real_boundary_is_written() {
380        // The only guard on this. A write-site conditional has nothing
381        // covering it the way a schema entry's omit opt-in does,
382        // and the corpus measurement that would catch a regression only runs
383        // by hand against a real install.
384        //
385        // Both directions, because each failure is different. Writing the
386        // empty list puts a field on the file that aborts the encounter's
387        // load; dropping a real boundary loses the encounter's shape.
388        let empty = Ute::default();
389        assert!(empty.geometry.is_empty());
390        assert!(empty.to_gff().root.field("Geometry").is_none());
391
392        let bounded = Ute {
393            geometry: vec![UteGeometryVertex {
394                x: 1.0,
395                y: 2.0,
396                z: 3.0,
397            }],
398            ..Ute::default()
399        };
400        let written = bounded.to_gff();
401        let Some(GffValue::List(vertices)) = written.root.field("Geometry") else {
402            panic!("a non-empty boundary must be written");
403        };
404        assert_eq!(vertices.len(), 1);
405    }
406
407    /// Build a minimal UTE GFF for testing.
408    fn make_test_ute_gff() -> Gff {
409        let mut root = GffStruct::new(-1);
410        root.push_field(gff_label!("Tag"), GffValue::String("TestEncounter".into()));
411        root.push_field(
412            gff_label!("TemplateResRef"),
413            GffValue::resref_lit("enc_test001"),
414        );
415        root.push_field(
416            gff_label!("LocalizedName"),
417            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(12345))),
418        );
419        root.push_field(
420            gff_label!("Comment"),
421            GffValue::String("test comment".into()),
422        );
423        root.push_field(gff_label!("PaletteID"), GffValue::UInt8(3));
424        root.push_field(gff_label!("Active"), GffValue::UInt8(1));
425        root.push_field(gff_label!("Reset"), GffValue::UInt8(1));
426        root.push_field(gff_label!("ResetTime"), GffValue::Int32(120));
427        root.push_field(gff_label!("Respawns"), GffValue::Int32(-1));
428        root.push_field(gff_label!("SpawnOption"), GffValue::Int32(1));
429        root.push_field(gff_label!("MaxCreatures"), GffValue::Int32(4));
430        root.push_field(gff_label!("RecCreatures"), GffValue::Int32(2));
431        root.push_field(gff_label!("PlayerOnly"), GffValue::UInt8(0));
432        root.push_field(gff_label!("Faction"), GffValue::UInt32(1));
433        root.push_field(gff_label!("DifficultyIndex"), GffValue::Int32(3));
434        root.push_field(gff_label!("Difficulty"), GffValue::Int32(2));
435        root.push_field(gff_label!("OnEntered"), GffValue::resref_lit("k_enc_enter"));
436        root.push_field(gff_label!("OnExit"), GffValue::resref_lit("k_enc_exit"));
437        root.push_field(gff_label!("OnHeartbeat"), GffValue::resref_lit(""));
438        root.push_field(
439            gff_label!("OnExhausted"),
440            GffValue::resref_lit("k_enc_exhaust"),
441        );
442        root.push_field(gff_label!("OnUserDefined"), GffValue::resref_lit(""));
443
444        // Creature list with two entries.
445        let mut c1 = GffStruct::new(0);
446        c1.push_field(gff_label!("ResRef"), GffValue::resref_lit("k_def_yourpaty"));
447        c1.push_field(gff_label!("CR"), GffValue::Single(3.0));
448        c1.push_field(gff_label!("SingleSpawn"), GffValue::UInt8(0));
449
450        let mut c2 = GffStruct::new(0);
451        c2.push_field(gff_label!("ResRef"), GffValue::resref_lit("k_def_darkjedi"));
452        c2.push_field(gff_label!("CR"), GffValue::Single(5.5));
453        c2.push_field(gff_label!("SingleSpawn"), GffValue::UInt8(1));
454
455        root.push_field(gff_label!("CreatureList"), GffValue::List(vec![c1, c2]));
456
457        Gff::new(*b"UTE ", root)
458    }
459
460    /// Build a fully-populated UTE GFF with all typed fields for roundtrip testing.
461    fn make_full_ute_gff() -> Gff {
462        let mut root = GffStruct::new(-1);
463
464        // Identity
465        root.push_field(gff_label!("Tag"), GffValue::String("FullEncounter".into()));
466        root.push_field(
467            gff_label!("TemplateResRef"),
468            GffValue::resref_lit("enc_full001"),
469        );
470        root.push_field(
471            gff_label!("LocalizedName"),
472            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(99999))),
473        );
474        root.push_field(gff_label!("Comment"), GffValue::String("full test".into()));
475        root.push_field(gff_label!("PaletteID"), GffValue::UInt8(7));
476
477        // Spawn config
478        root.push_field(gff_label!("Active"), GffValue::UInt8(1));
479        root.push_field(gff_label!("Reset"), GffValue::UInt8(0));
480        root.push_field(gff_label!("ResetTime"), GffValue::Int32(60));
481        root.push_field(gff_label!("Respawns"), GffValue::Int32(3));
482        root.push_field(gff_label!("SpawnOption"), GffValue::Int32(2));
483        root.push_field(gff_label!("MaxCreatures"), GffValue::Int32(6));
484        root.push_field(gff_label!("RecCreatures"), GffValue::Int32(3));
485        root.push_field(gff_label!("PlayerOnly"), GffValue::UInt8(1));
486        root.push_field(gff_label!("Faction"), GffValue::UInt32(42));
487
488        // Difficulty
489        root.push_field(gff_label!("DifficultyIndex"), GffValue::Int32(5));
490        root.push_field(gff_label!("Difficulty"), GffValue::Int32(4));
491
492        // Position
493        root.push_field(gff_label!("XPosition"), GffValue::Single(10.5));
494        root.push_field(gff_label!("YPosition"), GffValue::Single(20.25));
495        root.push_field(gff_label!("ZPosition"), GffValue::Single(-3.0));
496
497        // Scripts
498        root.push_field(gff_label!("OnEntered"), GffValue::resref_lit("s_enter"));
499        root.push_field(gff_label!("OnExit"), GffValue::resref_lit("s_exit"));
500        root.push_field(gff_label!("OnHeartbeat"), GffValue::resref_lit("s_hb"));
501        root.push_field(gff_label!("OnExhausted"), GffValue::resref_lit("s_exhaust"));
502        root.push_field(gff_label!("OnUserDefined"), GffValue::resref_lit("s_ud"));
503
504        // Runtime state
505        root.push_field(gff_label!("NumberSpawned"), GffValue::Int32(12));
506        root.push_field(gff_label!("HeartbeatDay"), GffValue::UInt32(100));
507        root.push_field(gff_label!("HeartbeatTime"), GffValue::UInt32(200));
508        root.push_field(gff_label!("LastSpawnDay"), GffValue::UInt32(101));
509        root.push_field(gff_label!("LastSpawnTime"), GffValue::UInt32(201));
510        root.push_field(gff_label!("LastEntered"), GffValue::UInt32(555));
511        root.push_field(gff_label!("LastLeft"), GffValue::UInt32(444));
512        root.push_field(gff_label!("Started"), GffValue::UInt8(1));
513        root.push_field(gff_label!("Exhausted"), GffValue::UInt8(0));
514        root.push_field(gff_label!("CurrentSpawns"), GffValue::Int32(3));
515        root.push_field(gff_label!("CustomScriptId"), GffValue::Int32(77));
516
517        // Area tracking
518        root.push_field(gff_label!("AreaListMaxSize"), GffValue::Int32(10));
519        root.push_field(gff_label!("SpawnPoolActive"), GffValue::Single(1.5));
520        root.push_field(gff_label!("AreaPoints"), GffValue::Single(2.75));
521
522        // Creature list
523        let mut c1 = GffStruct::new(0);
524        c1.push_field(gff_label!("ResRef"), GffValue::resref_lit("cr_sith01"));
525        c1.push_field(gff_label!("CR"), GffValue::Single(4.0));
526        c1.push_field(gff_label!("SingleSpawn"), GffValue::UInt8(1));
527        root.push_field(gff_label!("CreatureList"), GffValue::List(vec![c1]));
528
529        // Geometry
530        let mut g1 = GffStruct::new(0);
531        g1.push_field(gff_label!("X"), GffValue::Single(1.0));
532        g1.push_field(gff_label!("Y"), GffValue::Single(2.0));
533        g1.push_field(gff_label!("Z"), GffValue::Single(3.0));
534        let mut g2 = GffStruct::new(0);
535        g2.push_field(gff_label!("X"), GffValue::Single(4.0));
536        g2.push_field(gff_label!("Y"), GffValue::Single(5.0));
537        g2.push_field(gff_label!("Z"), GffValue::Single(6.0));
538        root.push_field(gff_label!("Geometry"), GffValue::List(vec![g1, g2]));
539
540        // SpawnPointList
541        let mut sp = GffStruct::new(0);
542        sp.push_field(gff_label!("X"), GffValue::Single(11.0));
543        sp.push_field(gff_label!("Y"), GffValue::Single(22.0));
544        sp.push_field(gff_label!("Z"), GffValue::Single(33.0));
545        sp.push_field(gff_label!("Orientation"), GffValue::Single(1.57));
546        root.push_field(gff_label!("SpawnPointList"), GffValue::List(vec![sp]));
547
548        // AreaList
549        let mut ae = GffStruct::new(0);
550        ae.push_field(gff_label!("AreaObject"), GffValue::UInt32(9001));
551        root.push_field(gff_label!("AreaList"), GffValue::List(vec![ae]));
552
553        // SpawnList
554        let mut se = GffStruct::new(0);
555        se.push_field(gff_label!("SpawnResRef"), GffValue::resref_lit("sp_ref01"));
556        se.push_field(gff_label!("SpawnCR"), GffValue::Single(2.5));
557        root.push_field(gff_label!("SpawnList"), GffValue::List(vec![se]));
558
559        Gff::new(*b"UTE ", root)
560    }
561
562    #[test]
563    fn reads_core_ute_fields() {
564        let gff = make_test_ute_gff();
565        let ute = Ute::from_gff(&gff).expect("must parse");
566
567        assert_eq!(ute.tag, "TestEncounter");
568        assert_eq!(ute.template_resref, "enc_test001");
569        assert_eq!(ute.name.string_ref.raw(), 12345);
570        assert_eq!(ute.comment, "test comment");
571        assert_eq!(ute.palette_id, 3);
572        assert!(ute.active);
573        assert!(ute.reset);
574        assert_eq!(ute.reset_time, 120);
575        assert_eq!(ute.respawns, -1);
576        assert_eq!(ute.spawn_option, 1);
577        assert_eq!(ute.max_creatures, 4);
578        assert_eq!(ute.rec_creatures, 2);
579        assert!(!ute.player_only);
580        assert_eq!(ute.faction_id, 1);
581        assert_eq!(ute.difficulty_index, 3);
582        assert_eq!(ute.difficulty, 2);
583        assert_eq!(ute.on_entered, "k_enc_enter");
584        assert_eq!(ute.on_exit, "k_enc_exit");
585        assert_eq!(ute.on_heartbeat, "");
586        assert_eq!(ute.on_exhausted, "k_enc_exhaust");
587        assert_eq!(ute.on_user_defined, "");
588    }
589
590    #[test]
591    fn reads_creature_list() {
592        let gff = make_test_ute_gff();
593        let ute = Ute::from_gff(&gff).expect("must parse");
594
595        assert_eq!(ute.creatures.len(), 2);
596        assert_eq!(ute.creatures[0].resref, "k_def_yourpaty");
597        assert_eq!(ute.creatures[0].cr, 3.0);
598        assert!(!ute.creatures[0].single_spawn);
599        assert_eq!(ute.creatures[1].resref, "k_def_darkjedi");
600        assert_eq!(ute.creatures[1].cr, 5.5);
601        assert!(ute.creatures[1].single_spawn);
602    }
603
604    #[test]
605    fn all_fields_survive_typed_roundtrip() {
606        let gff = make_full_ute_gff();
607        let ute = Ute::from_gff(&gff).expect("must parse full GFF");
608
609        // Verify all scalar fields read correctly.
610        assert_eq!(ute.tag, "FullEncounter");
611        assert_eq!(ute.template_resref, "enc_full001");
612        assert_eq!(ute.name.string_ref.raw(), 99999);
613        assert_eq!(ute.comment, "full test");
614        assert_eq!(ute.palette_id, 7);
615        assert!(ute.active);
616        assert!(!ute.reset);
617        assert_eq!(ute.reset_time, 60);
618        assert_eq!(ute.respawns, 3);
619        assert_eq!(ute.spawn_option, 2);
620        assert_eq!(ute.max_creatures, 6);
621        assert_eq!(ute.rec_creatures, 3);
622        assert!(ute.player_only);
623        assert_eq!(ute.faction_id, 42);
624        assert_eq!(ute.difficulty_index, 5);
625        assert_eq!(ute.difficulty, 4);
626        assert_eq!(ute.x_position, 10.5);
627        assert_eq!(ute.y_position, 20.25);
628        assert_eq!(ute.z_position, -3.0);
629        assert_eq!(ute.on_entered, "s_enter");
630        assert_eq!(ute.on_exit, "s_exit");
631        assert_eq!(ute.on_heartbeat, "s_hb");
632        assert_eq!(ute.on_exhausted, "s_exhaust");
633        assert_eq!(ute.on_user_defined, "s_ud");
634        assert_eq!(ute.number_spawned, 12);
635        assert_eq!(ute.heartbeat_day, 100);
636        assert_eq!(ute.heartbeat_time, 200);
637        assert_eq!(ute.last_spawn_day, 101);
638        assert_eq!(ute.last_spawn_time, 201);
639        assert_eq!(ute.last_entered, 555);
640        assert_eq!(ute.last_left, 444);
641        assert!(ute.started);
642        assert!(!ute.exhausted);
643        assert_eq!(ute.current_spawns, 3);
644        assert_eq!(ute.custom_script_id, 77);
645        assert_eq!(ute.area_list_max_size, 10);
646        assert_eq!(ute.spawn_pool_active, 1.5);
647        assert_eq!(ute.area_points, 2.75);
648
649        // Verify lists.
650        assert_eq!(ute.creatures.len(), 1);
651        assert_eq!(ute.creatures[0].resref, "cr_sith01");
652        assert_eq!(ute.creatures[0].cr, 4.0);
653        assert!(ute.creatures[0].single_spawn);
654
655        assert_eq!(ute.geometry.len(), 2);
656        assert_eq!(ute.geometry[0].x, 1.0);
657        assert_eq!(ute.geometry[1].y, 5.0);
658
659        assert_eq!(ute.spawn_points.len(), 1);
660        assert_eq!(ute.spawn_points[0].x, 11.0);
661        assert_eq!(ute.spawn_points[0].orientation, 1.57);
662
663        assert_eq!(ute.area_list.len(), 1);
664        assert_eq!(ute.area_list[0].area_object, 9001);
665
666        assert_eq!(ute.spawn_list.len(), 1);
667        assert_eq!(ute.spawn_list[0].spawn_resref, "sp_ref01");
668        assert_eq!(ute.spawn_list[0].spawn_cr, 2.5);
669
670        // Write and re-read - all typed fields must survive.
671        let bytes = author_ute_to_vec(&ute).expect("write succeeds");
672        let reparsed = read_ute_from_bytes(&bytes).expect("reparse succeeds");
673        assert_eq!(ute, reparsed);
674    }
675
676    #[test]
677    fn typed_edits_roundtrip_through_gff_writer() {
678        let gff = make_test_ute_gff();
679        let mut ute = Ute::from_gff(&gff).expect("must parse");
680        ute.tag = "EditedEncounter".into();
681        ute.max_creatures = 8;
682        ute.on_entered = ResRef::new("rust_on_enter").expect("valid test resref");
683        ute.creatures[0].resref = ResRef::new("k_new_creature").expect("valid test resref");
684
685        let bytes = author_ute_to_vec(&ute).expect("write succeeds");
686        let reparsed = read_ute_from_bytes(&bytes).expect("reparse succeeds");
687
688        assert_eq!(reparsed.tag, "EditedEncounter");
689        assert_eq!(reparsed.max_creatures, 8);
690        assert_eq!(reparsed.on_entered, "rust_on_enter");
691        assert_eq!(reparsed.creatures[0].resref, "k_new_creature");
692    }
693
694    #[test]
695    fn read_ute_from_reader_matches_bytes_path() {
696        let gff = make_test_ute_gff();
697        let bytes = {
698            let mut c = Cursor::new(Vec::new());
699            write_gff(&mut c, &gff).expect("test fixture must be valid");
700            c.into_inner()
701        };
702
703        let mut cursor = Cursor::new(&bytes);
704        let via_reader = read_ute(&mut cursor).expect("reader parse succeeds");
705        let via_bytes = read_ute_from_bytes(&bytes).expect("bytes parse succeeds");
706
707        assert_eq!(via_reader, via_bytes);
708    }
709
710    #[test]
711    fn rejects_non_ute_file_type() {
712        let mut gff = make_test_ute_gff();
713        gff.file_type = *b"UTT ";
714
715        let err = Ute::from_gff(&gff).expect_err("UTT must be rejected as UTE input");
716        assert!(matches!(
717            err,
718            UteError::UnsupportedFileType(file_type) if file_type == *b"UTT "
719        ));
720    }
721
722    #[test]
723    fn a_mistyped_localized_name_reads_as_absent() {
724        let mut gff = make_test_ute_gff();
725        gff.root
726            .fields
727            .retain(|field| field.label != "LocalizedName");
728        gff.root
729            .push_field(gff_label!("LocalizedName"), GffValue::UInt32(5));
730
731        let ute = Ute::from_gff(&gff).expect("a mistyped field is not a read failure");
732
733        assert_eq!(ute.name, GffLocalizedString::default());
734    }
735
736    #[test]
737    fn write_ute_matches_direct_gff_writer() {
738        let gff = make_test_ute_gff();
739        let ute = Ute::from_gff(&gff).expect("must parse");
740
741        let via_typed = author_ute_to_vec(&ute).expect("typed write succeeds");
742
743        let mut direct = Cursor::new(Vec::new());
744        write_gff(&mut direct, &ute.to_gff()).expect("direct write succeeds");
745
746        assert_eq!(via_typed, direct.into_inner());
747    }
748
749    #[test]
750    fn empty_creature_list_ok() {
751        let mut gff = make_test_ute_gff();
752        gff.root.fields.retain(|f| f.label != "CreatureList");
753
754        let ute = Ute::from_gff(&gff).expect("must parse");
755        assert!(ute.creatures.is_empty());
756    }
757
758    #[test]
759    fn schema_field_count() {
760        assert_eq!(Ute::schema().len(), 43);
761    }
762
763    #[test]
764    fn schema_no_duplicate_labels() {
765        let mut labels: Vec<&str> = Ute::schema().iter().map(|f| f.label.as_str()).collect();
766        labels.sort_unstable();
767        let before = labels.len();
768        labels.dedup();
769        assert_eq!(before, labels.len(), "duplicate labels in UTE schema");
770    }
771
772    #[test]
773    fn schema_lists_carry_their_elements() {
774        let element_count = |label: &str| {
775            let field = Ute::schema()
776                .iter()
777                .find(|f| f.label.as_str() == label)
778                .expect("declared");
779            match field.shape {
780                // Summed across parts: a schema is a sequence so a shared part
781                // can be referenced rather than copied.
782                Shape::List { element, .. } => element.iter().map(|p| p.len()).sum::<usize>(),
783                other => panic!("{label} is a list, got {other:?}"),
784            }
785        };
786        assert_eq!(element_count("CreatureList"), 3);
787        assert_eq!(element_count("Geometry"), 3);
788        assert_eq!(element_count("SpawnPointList"), 4);
789        assert_eq!(element_count("AreaList"), 1);
790        assert_eq!(element_count("SpawnList"), 2);
791    }
792}