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 crate::gff_helpers::{
41    get_bool, get_f32, get_i32, get_locstring, get_resref, get_string, get_u32, get_u8,
42    upsert_field,
43};
44use rakata_core::{ResRef, StrRef};
45use rakata_formats::{
46    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
47    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
48    GffValue,
49};
50use thiserror::Error;
51
52// ---------------------------------------------------------------------------
53// Sub-struct types
54// ---------------------------------------------------------------------------
55
56/// A single vertex in the encounter's boundary geometry (`Geometry` list).
57#[derive(Debug, Clone, PartialEq)]
58pub struct UteGeometryVertex {
59    /// X coordinate.
60    pub x: f32,
61    /// Y coordinate.
62    pub y: f32,
63    /// Z coordinate.
64    pub z: f32,
65}
66
67impl UteGeometryVertex {
68    fn from_gff_struct(s: &GffStruct) -> Self {
69        Self {
70            x: get_f32(s, "X").unwrap_or(0.0),
71            y: get_f32(s, "Y").unwrap_or(0.0),
72            z: get_f32(s, "Z").unwrap_or(0.0),
73        }
74    }
75
76    fn to_gff_struct(&self) -> GffStruct {
77        let mut s = GffStruct::new(0);
78        s.push_field("X", GffValue::Single(self.x));
79        s.push_field("Y", GffValue::Single(self.y));
80        s.push_field("Z", GffValue::Single(self.z));
81        s
82    }
83}
84
85/// A spawn point in the encounter (`SpawnPointList` list).
86#[derive(Debug, Clone, PartialEq)]
87pub struct UteSpawnPoint {
88    /// X coordinate.
89    pub x: f32,
90    /// Y coordinate.
91    pub y: f32,
92    /// Z coordinate.
93    pub z: f32,
94    /// Facing orientation in radians.
95    pub orientation: f32,
96}
97
98impl UteSpawnPoint {
99    fn from_gff_struct(s: &GffStruct) -> Self {
100        Self {
101            x: get_f32(s, "X").unwrap_or(0.0),
102            y: get_f32(s, "Y").unwrap_or(0.0),
103            z: get_f32(s, "Z").unwrap_or(0.0),
104            orientation: get_f32(s, "Orientation").unwrap_or(0.0),
105        }
106    }
107
108    fn to_gff_struct(&self) -> GffStruct {
109        let mut s = GffStruct::new(0);
110        s.push_field("X", GffValue::Single(self.x));
111        s.push_field("Y", GffValue::Single(self.y));
112        s.push_field("Z", GffValue::Single(self.z));
113        s.push_field("Orientation", GffValue::Single(self.orientation));
114        s
115    }
116}
117
118/// An entry in the encounter's area tracking list (`AreaList` list).
119#[derive(Debug, Clone, PartialEq)]
120pub struct UteAreaEntry {
121    /// Area object identifier (`AreaObject`).
122    pub area_object: u32,
123}
124
125impl UteAreaEntry {
126    fn from_gff_struct(s: &GffStruct) -> Self {
127        Self {
128            area_object: get_u32(s, "AreaObject").unwrap_or(0),
129        }
130    }
131
132    fn to_gff_struct(&self) -> GffStruct {
133        let mut s = GffStruct::new(0);
134        s.push_field("AreaObject", GffValue::UInt32(self.area_object));
135        s
136    }
137}
138
139/// An entry in the encounter's spawn resource list (`SpawnList` list).
140#[derive(Debug, Clone, PartialEq)]
141pub struct UteSpawnEntry {
142    /// Spawn creature resref (`SpawnResRef`).
143    pub spawn_resref: ResRef,
144    /// Spawn challenge rating (`SpawnCR`).
145    pub spawn_cr: f32,
146}
147
148impl UteSpawnEntry {
149    fn from_gff_struct(s: &GffStruct) -> Self {
150        Self {
151            spawn_resref: get_resref(s, "SpawnResRef").unwrap_or_default(),
152            spawn_cr: get_f32(s, "SpawnCR").unwrap_or(0.0),
153        }
154    }
155
156    fn to_gff_struct(&self) -> GffStruct {
157        let mut s = GffStruct::new(0);
158        s.push_field("SpawnResRef", GffValue::ResRef(self.spawn_resref));
159        s.push_field("SpawnCR", GffValue::Single(self.spawn_cr));
160        s
161    }
162}
163
164// ---------------------------------------------------------------------------
165// UteCreature
166// ---------------------------------------------------------------------------
167
168/// A single entry in the encounter's creature table.
169#[derive(Debug, Clone, PartialEq)]
170pub struct UteCreature {
171    /// Creature template resref (`ResRef`).
172    pub resref: ResRef,
173    /// Challenge rating (`CR`).
174    pub cr: f32,
175    /// Single-spawn flag (`SingleSpawn`).
176    pub single_spawn: bool,
177}
178
179impl Default for UteCreature {
180    fn default() -> Self {
181        Self {
182            resref: ResRef::blank(),
183            cr: 0.0,
184            single_spawn: false,
185        }
186    }
187}
188
189impl UteCreature {
190    fn from_gff_struct(s: &GffStruct) -> Self {
191        Self {
192            resref: get_resref(s, "ResRef").unwrap_or_default(),
193            cr: get_f32(s, "CR").unwrap_or(0.0),
194            single_spawn: get_bool(s, "SingleSpawn").unwrap_or(false),
195        }
196    }
197
198    fn to_gff_struct(&self) -> GffStruct {
199        let mut s = GffStruct::new(0);
200        s.push_field("ResRef", GffValue::ResRef(self.resref));
201        s.push_field("CR", GffValue::Single(self.cr));
202        s.push_field("SingleSpawn", GffValue::UInt8(u8::from(self.single_spawn)));
203        s
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Ute
209// ---------------------------------------------------------------------------
210
211/// Typed UTE model built from/to [`Gff`] data.
212#[derive(Debug, Clone, PartialEq)]
213pub struct Ute {
214    // --- Identity ---
215    /// Encounter template resref (`TemplateResRef`). Legacy artifact completely ignored by the engine.
216    pub template_resref: ResRef,
217    /// Encounter tag (`Tag`).
218    pub tag: String,
219    /// Localized encounter name (`LocalizedName`).
220    pub name: GffLocalizedString,
221    /// Toolset comment (`Comment`). Legacy artifact completely ignored by the engine.
222    pub comment: String,
223    /// Palette ID (`PaletteID`). Legacy artifact completely ignored by the engine.
224    pub palette_id: u8,
225
226    // --- Spawn configuration ---
227    /// Whether the encounter is active (`Active`). Encounter must contain at least one creature if active.
228    pub active: bool,
229    /// Whether the encounter resets (`Reset`).
230    pub reset: bool,
231    /// Reset time in seconds (`ResetTime`).
232    pub reset_time: i32,
233    /// Number of respawns (`Respawns`).
234    pub respawns: i32,
235    /// Spawn option flag (`SpawnOption`).
236    pub spawn_option: i32,
237    /// Maximum creatures (`MaxCreatures`).
238    pub max_creatures: i32,
239    /// Recommended creatures (`RecCreatures`).
240    pub rec_creatures: i32,
241    /// Player-only flag (`PlayerOnly`).
242    pub player_only: bool,
243    /// Faction identifier (`Faction`).
244    pub faction_id: u32,
245
246    // --- Difficulty ---
247    /// Difficulty index (`DifficultyIndex`).
248    pub difficulty_index: i32,
249    /// Difficulty value (`Difficulty`). Ignored by the engine in favor of `difficulty_index` when statically defined.
250    pub difficulty: i32,
251
252    // --- Position ---
253    /// X position in area (`XPosition`).
254    pub x_position: f32,
255    /// Y position in area (`YPosition`).
256    pub y_position: f32,
257    /// Z position in area (`ZPosition`).
258    pub z_position: f32,
259
260    // --- Scripts ---
261    /// On-entered script (`OnEntered`).
262    pub on_entered: ResRef,
263    /// On-exit script (`OnExit`).
264    pub on_exit: ResRef,
265    /// On-heartbeat script (`OnHeartbeat`).
266    pub on_heartbeat: ResRef,
267    /// On-exhausted script (`OnExhausted`).
268    pub on_exhausted: ResRef,
269    /// On-user-defined script (`OnUserDefined`).
270    pub on_user_defined: ResRef,
271
272    // --- Runtime state ---
273    /// Number of creatures spawned so far (`NumberSpawned`).
274    pub number_spawned: i32,
275    /// Heartbeat day counter (`HeartbeatDay`).
276    pub heartbeat_day: u32,
277    /// Heartbeat time counter (`HeartbeatTime`).
278    pub heartbeat_time: u32,
279    /// Day of last spawn (`LastSpawnDay`).
280    pub last_spawn_day: u32,
281    /// Time of last spawn (`LastSpawnTime`).
282    pub last_spawn_time: u32,
283    /// Last-entered object ID (`LastEntered`).
284    pub last_entered: u32,
285    /// Last-left object ID (`LastLeft`).
286    pub last_left: u32,
287    /// Whether the encounter has started (`Started`).
288    pub started: bool,
289    /// Whether the encounter is exhausted (`Exhausted`).
290    pub exhausted: bool,
291    /// Current live spawn count (`CurrentSpawns`).
292    pub current_spawns: i32,
293    /// Custom script identifier (`CustomScriptId`).
294    pub custom_script_id: i32,
295
296    // --- Area tracking ---
297    /// Maximum size of the area list (`AreaListMaxSize`).
298    pub area_list_max_size: i32,
299    /// Active spawn pool value (`SpawnPoolActive`).
300    pub spawn_pool_active: f32,
301    /// Area points value (`AreaPoints`).
302    pub area_points: f32,
303
304    // --- Creature list ---
305    /// Creatures that can spawn in this encounter (`CreatureList`).
306    pub creatures: Vec<UteCreature>,
307
308    // --- Geometry ---
309    /// Boundary geometry vertices (`Geometry`). If an empty list is explicitly present in the data, the engine will crash on load.
310    pub geometry: Vec<UteGeometryVertex>,
311
312    // --- Spawn points ---
313    /// Spawn point positions (`SpawnPointList`).
314    pub spawn_points: Vec<UteSpawnPoint>,
315
316    // --- Area list ---
317    /// Area tracking entries (`AreaList`).
318    pub area_list: Vec<UteAreaEntry>,
319
320    // --- Spawn list ---
321    /// Spawn resource entries (`SpawnList`).
322    pub spawn_list: Vec<UteSpawnEntry>,
323}
324
325impl Default for Ute {
326    fn default() -> Self {
327        Self {
328            template_resref: ResRef::blank(),
329            tag: String::new(),
330            name: GffLocalizedString::new(StrRef::invalid()),
331            comment: String::new(),
332            palette_id: 0,
333            active: true,
334            reset: false,
335            reset_time: 0,
336            respawns: 0,
337            spawn_option: 0,
338            max_creatures: 0,
339            rec_creatures: 0,
340            player_only: false,
341            faction_id: 0,
342            difficulty_index: 0,
343            difficulty: 0,
344            x_position: 0.0,
345            y_position: 0.0,
346            z_position: 0.0,
347            on_entered: ResRef::blank(),
348            on_exit: ResRef::blank(),
349            on_heartbeat: ResRef::blank(),
350            on_exhausted: ResRef::blank(),
351            on_user_defined: ResRef::blank(),
352            number_spawned: 0,
353            heartbeat_day: 0,
354            heartbeat_time: 0,
355            last_spawn_day: 0,
356            last_spawn_time: 0,
357            last_entered: 0,
358            last_left: 0,
359            started: false,
360            exhausted: false,
361            current_spawns: 0,
362            custom_script_id: 0,
363            area_list_max_size: 0,
364            spawn_pool_active: 0.0,
365            area_points: 0.0,
366            creatures: Vec::new(),
367            geometry: Vec::new(),
368            spawn_points: Vec::new(),
369            area_list: Vec::new(),
370            spawn_list: Vec::new(),
371        }
372    }
373}
374
375impl Ute {
376    /// Creates an empty UTE value.
377    pub fn new() -> Self {
378        Self::default()
379    }
380
381    /// Builds typed UTE data from a parsed GFF container.
382    pub fn from_gff(gff: &Gff) -> Result<Self, UteError> {
383        if gff.file_type != *b"UTE " && gff.file_type != *b"GFF " {
384            return Err(UteError::UnsupportedFileType(gff.file_type));
385        }
386
387        let root = &gff.root;
388
389        if matches!(root.field("LocalizedName"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
390        {
391            return Err(UteError::TypeMismatch {
392                field: "LocalizedName",
393                expected: "LocalizedString",
394            });
395        }
396
397        let creatures = match root.field("CreatureList") {
398            Some(GffValue::List(elements)) => {
399                elements.iter().map(UteCreature::from_gff_struct).collect()
400            }
401            _ => Vec::new(),
402        };
403
404        let geometry = match root.field("Geometry") {
405            Some(GffValue::List(structs)) => structs
406                .iter()
407                .map(UteGeometryVertex::from_gff_struct)
408                .collect(),
409            _ => Vec::new(),
410        };
411
412        let spawn_points = match root.field("SpawnPointList") {
413            Some(GffValue::List(structs)) => {
414                structs.iter().map(UteSpawnPoint::from_gff_struct).collect()
415            }
416            _ => Vec::new(),
417        };
418
419        let area_list = match root.field("AreaList") {
420            Some(GffValue::List(structs)) => {
421                structs.iter().map(UteAreaEntry::from_gff_struct).collect()
422            }
423            _ => Vec::new(),
424        };
425
426        let spawn_list = match root.field("SpawnList") {
427            Some(GffValue::List(structs)) => {
428                structs.iter().map(UteSpawnEntry::from_gff_struct).collect()
429            }
430            _ => Vec::new(),
431        };
432
433        Ok(Self {
434            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
435            tag: get_string(root, "Tag").unwrap_or_default(),
436            name: get_locstring(root, "LocalizedName")
437                .cloned()
438                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
439            comment: get_string(root, "Comment").unwrap_or_default(),
440            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
441            active: get_bool(root, "Active").unwrap_or(true),
442            reset: get_bool(root, "Reset").unwrap_or(false),
443            reset_time: get_i32(root, "ResetTime").unwrap_or(0),
444            respawns: get_i32(root, "Respawns").unwrap_or(0),
445            spawn_option: get_i32(root, "SpawnOption").unwrap_or(0),
446            max_creatures: get_i32(root, "MaxCreatures").unwrap_or(0),
447            rec_creatures: get_i32(root, "RecCreatures").unwrap_or(0),
448            player_only: get_bool(root, "PlayerOnly").unwrap_or(false),
449            faction_id: get_u32(root, "Faction").unwrap_or(0),
450            difficulty_index: get_i32(root, "DifficultyIndex").unwrap_or(0),
451            difficulty: get_i32(root, "Difficulty").unwrap_or(0),
452            x_position: get_f32(root, "XPosition").unwrap_or(0.0),
453            y_position: get_f32(root, "YPosition").unwrap_or(0.0),
454            z_position: get_f32(root, "ZPosition").unwrap_or(0.0),
455            on_entered: get_resref(root, "OnEntered").unwrap_or_default(),
456            on_exit: get_resref(root, "OnExit").unwrap_or_default(),
457            on_heartbeat: get_resref(root, "OnHeartbeat").unwrap_or_default(),
458            on_exhausted: get_resref(root, "OnExhausted").unwrap_or_default(),
459            on_user_defined: get_resref(root, "OnUserDefined").unwrap_or_default(),
460            number_spawned: get_i32(root, "NumberSpawned").unwrap_or(0),
461            heartbeat_day: get_u32(root, "HeartbeatDay").unwrap_or(0),
462            heartbeat_time: get_u32(root, "HeartbeatTime").unwrap_or(0),
463            last_spawn_day: get_u32(root, "LastSpawnDay").unwrap_or(0),
464            last_spawn_time: get_u32(root, "LastSpawnTime").unwrap_or(0),
465            last_entered: get_u32(root, "LastEntered").unwrap_or(0),
466            last_left: get_u32(root, "LastLeft").unwrap_or(0),
467            started: get_bool(root, "Started").unwrap_or(false),
468            exhausted: get_bool(root, "Exhausted").unwrap_or(false),
469            current_spawns: get_i32(root, "CurrentSpawns").unwrap_or(0),
470            custom_script_id: get_i32(root, "CustomScriptId").unwrap_or(0),
471            area_list_max_size: get_i32(root, "AreaListMaxSize").unwrap_or(0),
472            spawn_pool_active: get_f32(root, "SpawnPoolActive").unwrap_or(0.0),
473            area_points: get_f32(root, "AreaPoints").unwrap_or(0.0),
474            creatures,
475            geometry,
476            spawn_points,
477            area_list,
478            spawn_list,
479        })
480    }
481
482    /// Converts this typed UTE value into a GFF container.
483    ///
484    /// All fields are written from scratch - no source template is retained.
485    pub fn to_gff(&self) -> Gff {
486        let mut root = GffStruct::new(-1);
487
488        upsert_field(
489            &mut root,
490            "TemplateResRef",
491            GffValue::ResRef(self.template_resref),
492        );
493        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
494        upsert_field(
495            &mut root,
496            "LocalizedName",
497            GffValue::LocalizedString(self.name.clone()),
498        );
499        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
500        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
501
502        upsert_field(&mut root, "Active", GffValue::UInt8(u8::from(self.active)));
503        upsert_field(&mut root, "Reset", GffValue::UInt8(u8::from(self.reset)));
504        upsert_field(&mut root, "ResetTime", GffValue::Int32(self.reset_time));
505        upsert_field(&mut root, "Respawns", GffValue::Int32(self.respawns));
506        upsert_field(&mut root, "SpawnOption", GffValue::Int32(self.spawn_option));
507        upsert_field(
508            &mut root,
509            "MaxCreatures",
510            GffValue::Int32(self.max_creatures),
511        );
512        upsert_field(
513            &mut root,
514            "RecCreatures",
515            GffValue::Int32(self.rec_creatures),
516        );
517        upsert_field(
518            &mut root,
519            "PlayerOnly",
520            GffValue::UInt8(u8::from(self.player_only)),
521        );
522        upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
523
524        upsert_field(
525            &mut root,
526            "DifficultyIndex",
527            GffValue::Int32(self.difficulty_index),
528        );
529        upsert_field(&mut root, "Difficulty", GffValue::Int32(self.difficulty));
530
531        upsert_field(&mut root, "XPosition", GffValue::Single(self.x_position));
532        upsert_field(&mut root, "YPosition", GffValue::Single(self.y_position));
533        upsert_field(&mut root, "ZPosition", GffValue::Single(self.z_position));
534
535        upsert_field(&mut root, "OnEntered", GffValue::ResRef(self.on_entered));
536        upsert_field(&mut root, "OnExit", GffValue::ResRef(self.on_exit));
537        upsert_field(
538            &mut root,
539            "OnHeartbeat",
540            GffValue::ResRef(self.on_heartbeat),
541        );
542        upsert_field(
543            &mut root,
544            "OnExhausted",
545            GffValue::ResRef(self.on_exhausted),
546        );
547        upsert_field(
548            &mut root,
549            "OnUserDefined",
550            GffValue::ResRef(self.on_user_defined),
551        );
552
553        upsert_field(
554            &mut root,
555            "NumberSpawned",
556            GffValue::Int32(self.number_spawned),
557        );
558        upsert_field(
559            &mut root,
560            "HeartbeatDay",
561            GffValue::UInt32(self.heartbeat_day),
562        );
563        upsert_field(
564            &mut root,
565            "HeartbeatTime",
566            GffValue::UInt32(self.heartbeat_time),
567        );
568        upsert_field(
569            &mut root,
570            "LastSpawnDay",
571            GffValue::UInt32(self.last_spawn_day),
572        );
573        upsert_field(
574            &mut root,
575            "LastSpawnTime",
576            GffValue::UInt32(self.last_spawn_time),
577        );
578        upsert_field(
579            &mut root,
580            "LastEntered",
581            GffValue::UInt32(self.last_entered),
582        );
583        upsert_field(&mut root, "LastLeft", GffValue::UInt32(self.last_left));
584        upsert_field(
585            &mut root,
586            "Started",
587            GffValue::UInt8(u8::from(self.started)),
588        );
589        upsert_field(
590            &mut root,
591            "Exhausted",
592            GffValue::UInt8(u8::from(self.exhausted)),
593        );
594        upsert_field(
595            &mut root,
596            "CurrentSpawns",
597            GffValue::Int32(self.current_spawns),
598        );
599        upsert_field(
600            &mut root,
601            "CustomScriptId",
602            GffValue::Int32(self.custom_script_id),
603        );
604
605        upsert_field(
606            &mut root,
607            "AreaListMaxSize",
608            GffValue::Int32(self.area_list_max_size),
609        );
610        upsert_field(
611            &mut root,
612            "SpawnPoolActive",
613            GffValue::Single(self.spawn_pool_active),
614        );
615        upsert_field(&mut root, "AreaPoints", GffValue::Single(self.area_points));
616
617        let creature_structs: Vec<GffStruct> =
618            self.creatures.iter().map(|c| c.to_gff_struct()).collect();
619        upsert_field(&mut root, "CreatureList", GffValue::List(creature_structs));
620
621        let geometry_structs: Vec<GffStruct> =
622            self.geometry.iter().map(|v| v.to_gff_struct()).collect();
623        upsert_field(&mut root, "Geometry", GffValue::List(geometry_structs));
624
625        let spawn_point_structs: Vec<GffStruct> = self
626            .spawn_points
627            .iter()
628            .map(|p| p.to_gff_struct())
629            .collect();
630        upsert_field(
631            &mut root,
632            "SpawnPointList",
633            GffValue::List(spawn_point_structs),
634        );
635
636        let area_list_structs: Vec<GffStruct> =
637            self.area_list.iter().map(|a| a.to_gff_struct()).collect();
638        upsert_field(&mut root, "AreaList", GffValue::List(area_list_structs));
639
640        let spawn_list_structs: Vec<GffStruct> =
641            self.spawn_list.iter().map(|e| e.to_gff_struct()).collect();
642        upsert_field(&mut root, "SpawnList", GffValue::List(spawn_list_structs));
643
644        Gff::new(*b"UTE ", root)
645    }
646}
647
648/// Errors produced while reading or writing typed UTE data.
649#[derive(Debug, Error)]
650pub enum UteError {
651    /// Source file type is not supported by this parser.
652    #[error("unsupported UTE file type: {0:?}")]
653    UnsupportedFileType([u8; 4]),
654    /// A required container field had an unexpected runtime type.
655    #[error("UTE field `{field}` has incompatible type (expected {expected})")]
656    TypeMismatch {
657        /// Field label where mismatch occurred.
658        field: &'static str,
659        /// Expected runtime value kind.
660        expected: &'static str,
661    },
662    /// Underlying GFF parser/writer error.
663    #[error(transparent)]
664    Gff(#[from] GffBinaryError),
665}
666
667/// Reads typed UTE data from a reader at the current stream position.
668#[cfg_attr(
669    feature = "tracing",
670    tracing::instrument(level = "debug", skip(reader))
671)]
672pub fn read_ute<R: Read>(reader: &mut R) -> Result<Ute, UteError> {
673    let gff = read_gff(reader)?;
674    Ute::from_gff(&gff)
675}
676
677/// Reads typed UTE data directly from bytes.
678#[cfg_attr(
679    feature = "tracing",
680    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
681)]
682pub fn read_ute_from_bytes(bytes: &[u8]) -> Result<Ute, UteError> {
683    let gff = read_gff_from_bytes(bytes)?;
684    Ute::from_gff(&gff)
685}
686
687/// Writes typed UTE data to an output writer.
688#[cfg_attr(
689    feature = "tracing",
690    tracing::instrument(level = "debug", skip(writer, ute))
691)]
692pub fn write_ute<W: Write>(writer: &mut W, ute: &Ute) -> Result<(), UteError> {
693    let gff = ute.to_gff();
694    write_gff(writer, &gff)?;
695    Ok(())
696}
697
698/// Serializes typed UTE data into a byte vector.
699#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(ute)))]
700pub fn write_ute_to_vec(ute: &Ute) -> Result<Vec<u8>, UteError> {
701    let mut cursor = Cursor::new(Vec::new());
702    write_ute(&mut cursor, ute)?;
703    Ok(cursor.into_inner())
704}
705
706/// UTE `Geometry` list entry child schema.
707static GEOMETRY_CHILDREN: &[FieldSchema] = &[
708    FieldSchema {
709        label: "X",
710        expected_type: GffType::Single,
711        life: FieldLife::Live,
712        required: false,
713        absent: AbsentDefault::Unverified,
714        children: None,
715        constraint: None,
716    },
717    FieldSchema {
718        label: "Y",
719        expected_type: GffType::Single,
720        life: FieldLife::Live,
721        required: false,
722        absent: AbsentDefault::Unverified,
723        children: None,
724        constraint: None,
725    },
726    FieldSchema {
727        label: "Z",
728        expected_type: GffType::Single,
729        life: FieldLife::Live,
730        required: false,
731        absent: AbsentDefault::Unverified,
732        children: None,
733        constraint: None,
734    },
735];
736
737/// UTE `CreatureList` list entry child schema.
738static CREATURE_LIST_CHILDREN: &[FieldSchema] = &[
739    FieldSchema {
740        label: "ResRef",
741        expected_type: GffType::ResRef,
742        life: FieldLife::Live,
743        required: false,
744        absent: AbsentDefault::Unverified,
745        children: None,
746        constraint: None,
747    },
748    FieldSchema {
749        label: "CR",
750        expected_type: GffType::Single,
751        life: FieldLife::Live,
752        required: false,
753        absent: AbsentDefault::Unverified,
754        children: None,
755        constraint: None,
756    },
757    FieldSchema {
758        label: "SingleSpawn",
759        expected_type: GffType::UInt8,
760        life: FieldLife::Live,
761        required: false,
762        absent: AbsentDefault::Unverified,
763        children: None,
764        constraint: None,
765    },
766];
767
768/// UTE `SpawnPointList` list entry child schema.
769static SPAWN_POINT_LIST_CHILDREN: &[FieldSchema] = &[
770    FieldSchema {
771        label: "X",
772        expected_type: GffType::Single,
773        life: FieldLife::Live,
774        required: false,
775        absent: AbsentDefault::Unverified,
776        children: None,
777        constraint: None,
778    },
779    FieldSchema {
780        label: "Y",
781        expected_type: GffType::Single,
782        life: FieldLife::Live,
783        required: false,
784        absent: AbsentDefault::Unverified,
785        children: None,
786        constraint: None,
787    },
788    FieldSchema {
789        label: "Z",
790        expected_type: GffType::Single,
791        life: FieldLife::Live,
792        required: false,
793        absent: AbsentDefault::Unverified,
794        children: None,
795        constraint: None,
796    },
797    FieldSchema {
798        label: "Orientation",
799        expected_type: GffType::Single,
800        life: FieldLife::Live,
801        required: false,
802        absent: AbsentDefault::Unverified,
803        children: None,
804        constraint: None,
805    },
806];
807
808/// UTE `AreaList` list entry child schema.
809static AREA_LIST_CHILDREN: &[FieldSchema] = &[FieldSchema {
810    label: "AreaObject",
811    expected_type: GffType::UInt32,
812    life: FieldLife::Live,
813    required: false,
814    absent: AbsentDefault::Unverified,
815    children: None,
816    constraint: None,
817}];
818
819/// UTE `SpawnList` list entry child schema.
820static SPAWN_LIST_CHILDREN: &[FieldSchema] = &[
821    FieldSchema {
822        label: "SpawnResRef",
823        expected_type: GffType::ResRef,
824        life: FieldLife::Live,
825        required: false,
826        absent: AbsentDefault::Unverified,
827        children: None,
828        constraint: None,
829    },
830    FieldSchema {
831        label: "SpawnCR",
832        expected_type: GffType::Single,
833        life: FieldLife::Live,
834        required: false,
835        absent: AbsentDefault::Unverified,
836        children: None,
837        constraint: None,
838    },
839];
840
841impl GffSchema for Ute {
842    fn schema() -> &'static [FieldSchema] {
843        static SCHEMA: &[FieldSchema] = &[
844            // --- Identity ---
845            FieldSchema {
846                label: "Tag",
847                expected_type: GffType::String,
848                life: FieldLife::Live,
849                required: false,
850                absent: AbsentDefault::Unverified,
851                children: None,
852                constraint: None,
853            },
854            FieldSchema {
855                label: "LocalizedName",
856                expected_type: GffType::LocalizedString,
857                life: FieldLife::Live,
858                required: false,
859                absent: AbsentDefault::Unverified,
860                children: None,
861                constraint: None,
862            },
863            // --- Spawn configuration ---
864            FieldSchema {
865                label: "Active",
866                expected_type: GffType::UInt8,
867                life: FieldLife::Live,
868                required: false,
869                absent: AbsentDefault::Unverified,
870                children: None,
871                constraint: None,
872            },
873            FieldSchema {
874                label: "Reset",
875                expected_type: GffType::UInt8,
876                life: FieldLife::Live,
877                required: false,
878                absent: AbsentDefault::Unverified,
879                children: None,
880                constraint: None,
881            },
882            FieldSchema {
883                label: "ResetTime",
884                expected_type: GffType::Int32,
885                life: FieldLife::Live,
886                required: false,
887                absent: AbsentDefault::Unverified,
888                children: None,
889                constraint: None,
890            },
891            FieldSchema {
892                label: "Respawns",
893                expected_type: GffType::Int32,
894                life: FieldLife::Live,
895                required: false,
896                absent: AbsentDefault::Unverified,
897                children: None,
898                constraint: None,
899            },
900            FieldSchema {
901                label: "SpawnOption",
902                expected_type: GffType::Int32,
903                life: FieldLife::Live,
904                required: false,
905                absent: AbsentDefault::Unverified,
906                children: None,
907                constraint: None,
908            },
909            FieldSchema {
910                label: "MaxCreatures",
911                expected_type: GffType::Int32,
912                life: FieldLife::Live,
913                required: false,
914                absent: AbsentDefault::Unverified,
915                children: None,
916                constraint: None,
917            },
918            FieldSchema {
919                label: "RecCreatures",
920                expected_type: GffType::Int32,
921                life: FieldLife::Live,
922                required: false,
923                absent: AbsentDefault::Unverified,
924                children: None,
925                constraint: None,
926            },
927            FieldSchema {
928                label: "PlayerOnly",
929                expected_type: GffType::UInt8,
930                life: FieldLife::Live,
931                required: false,
932                absent: AbsentDefault::Unverified,
933                children: None,
934                constraint: None,
935            },
936            FieldSchema {
937                label: "Faction",
938                expected_type: GffType::UInt32,
939                life: FieldLife::Live,
940                required: false,
941                absent: AbsentDefault::Unverified,
942                children: None,
943                constraint: None,
944            },
945            // --- Difficulty ---
946            FieldSchema {
947                label: "DifficultyIndex",
948                expected_type: GffType::Int32,
949                life: FieldLife::Live,
950                required: false,
951                absent: AbsentDefault::Unverified,
952                children: None,
953                constraint: None,
954            },
955            FieldSchema {
956                label: "Difficulty",
957                expected_type: GffType::Int32,
958                life: FieldLife::Live,
959                required: false,
960                absent: AbsentDefault::Unverified,
961                children: None,
962                constraint: None,
963            },
964            // --- Position ---
965            FieldSchema {
966                label: "XPosition",
967                expected_type: GffType::Single,
968                life: FieldLife::Live,
969                required: false,
970                absent: AbsentDefault::Unverified,
971                children: None,
972                constraint: None,
973            },
974            FieldSchema {
975                label: "YPosition",
976                expected_type: GffType::Single,
977                life: FieldLife::Live,
978                required: false,
979                absent: AbsentDefault::Unverified,
980                children: None,
981                constraint: None,
982            },
983            FieldSchema {
984                label: "ZPosition",
985                expected_type: GffType::Single,
986                life: FieldLife::Live,
987                required: false,
988                absent: AbsentDefault::Unverified,
989                children: None,
990                constraint: None,
991            },
992            // --- Scripts (5) ---
993            FieldSchema {
994                label: "OnEntered",
995                expected_type: GffType::ResRef,
996                life: FieldLife::Live,
997                required: false,
998                absent: AbsentDefault::Unverified,
999                children: None,
1000                constraint: None,
1001            },
1002            FieldSchema {
1003                label: "OnExit",
1004                expected_type: GffType::ResRef,
1005                life: FieldLife::Live,
1006                required: false,
1007                absent: AbsentDefault::Unverified,
1008                children: None,
1009                constraint: None,
1010            },
1011            FieldSchema {
1012                label: "OnHeartbeat",
1013                expected_type: GffType::ResRef,
1014                life: FieldLife::Live,
1015                required: false,
1016                absent: AbsentDefault::Unverified,
1017                children: None,
1018                constraint: None,
1019            },
1020            FieldSchema {
1021                label: "OnExhausted",
1022                expected_type: GffType::ResRef,
1023                life: FieldLife::Live,
1024                required: false,
1025                absent: AbsentDefault::Unverified,
1026                children: None,
1027                constraint: None,
1028            },
1029            FieldSchema {
1030                label: "OnUserDefined",
1031                expected_type: GffType::ResRef,
1032                life: FieldLife::Live,
1033                required: false,
1034                absent: AbsentDefault::Unverified,
1035                children: None,
1036                constraint: None,
1037            },
1038            // --- Runtime state ---
1039            FieldSchema {
1040                label: "NumberSpawned",
1041                expected_type: GffType::Int32,
1042                life: FieldLife::Live,
1043                required: false,
1044                absent: AbsentDefault::Unverified,
1045                children: None,
1046                constraint: None,
1047            },
1048            FieldSchema {
1049                label: "HeartbeatDay",
1050                expected_type: GffType::UInt32,
1051                life: FieldLife::Live,
1052                required: false,
1053                absent: AbsentDefault::Unverified,
1054                children: None,
1055                constraint: None,
1056            },
1057            FieldSchema {
1058                label: "HeartbeatTime",
1059                expected_type: GffType::UInt32,
1060                life: FieldLife::Live,
1061                required: false,
1062                absent: AbsentDefault::Unverified,
1063                children: None,
1064                constraint: None,
1065            },
1066            FieldSchema {
1067                label: "LastSpawnDay",
1068                expected_type: GffType::UInt32,
1069                life: FieldLife::Live,
1070                required: false,
1071                absent: AbsentDefault::Unverified,
1072                children: None,
1073                constraint: None,
1074            },
1075            FieldSchema {
1076                label: "LastSpawnTime",
1077                expected_type: GffType::UInt32,
1078                life: FieldLife::Live,
1079                required: false,
1080                absent: AbsentDefault::Unverified,
1081                children: None,
1082                constraint: None,
1083            },
1084            FieldSchema {
1085                label: "LastEntered",
1086                expected_type: GffType::UInt32,
1087                life: FieldLife::Live,
1088                required: false,
1089                absent: AbsentDefault::Unverified,
1090                children: None,
1091                constraint: None,
1092            },
1093            FieldSchema {
1094                label: "LastLeft",
1095                expected_type: GffType::UInt32,
1096                life: FieldLife::Live,
1097                required: false,
1098                absent: AbsentDefault::Unverified,
1099                children: None,
1100                constraint: None,
1101            },
1102            FieldSchema {
1103                label: "Started",
1104                expected_type: GffType::UInt8,
1105                life: FieldLife::Live,
1106                required: false,
1107                absent: AbsentDefault::Unverified,
1108                children: None,
1109                constraint: None,
1110            },
1111            FieldSchema {
1112                label: "Exhausted",
1113                expected_type: GffType::UInt8,
1114                life: FieldLife::Live,
1115                required: false,
1116                absent: AbsentDefault::Unverified,
1117                children: None,
1118                constraint: None,
1119            },
1120            FieldSchema {
1121                label: "CurrentSpawns",
1122                expected_type: GffType::Int32,
1123                life: FieldLife::Live,
1124                required: false,
1125                absent: AbsentDefault::Unverified,
1126                children: None,
1127                constraint: None,
1128            },
1129            FieldSchema {
1130                label: "CustomScriptId",
1131                expected_type: GffType::Int32,
1132                life: FieldLife::Live,
1133                required: false,
1134                absent: AbsentDefault::Unverified,
1135                children: None,
1136                constraint: None,
1137            },
1138            // --- Area tracking ---
1139            FieldSchema {
1140                label: "AreaListMaxSize",
1141                expected_type: GffType::Int32,
1142                life: FieldLife::Live,
1143                required: false,
1144                absent: AbsentDefault::Unverified,
1145                children: None,
1146                constraint: None,
1147            },
1148            FieldSchema {
1149                label: "SpawnPoolActive",
1150                expected_type: GffType::Single,
1151                life: FieldLife::Live,
1152                required: false,
1153                absent: AbsentDefault::Unverified,
1154                children: None,
1155                constraint: None,
1156            },
1157            FieldSchema {
1158                label: "AreaPoints",
1159                expected_type: GffType::Single,
1160                life: FieldLife::Live,
1161                required: false,
1162                absent: AbsentDefault::Unverified,
1163                children: None,
1164                constraint: None,
1165            },
1166            // --- Engine-read lists ---
1167            FieldSchema {
1168                label: "Geometry",
1169                expected_type: GffType::List,
1170                life: FieldLife::Live,
1171                required: false,
1172                absent: AbsentDefault::Unverified,
1173                children: Some(GEOMETRY_CHILDREN),
1174                constraint: None,
1175            },
1176            FieldSchema {
1177                label: "CreatureList",
1178                expected_type: GffType::List,
1179                life: FieldLife::Live,
1180                required: false,
1181                absent: AbsentDefault::Unverified,
1182                children: Some(CREATURE_LIST_CHILDREN),
1183                constraint: None,
1184            },
1185            FieldSchema {
1186                label: "SpawnPointList",
1187                expected_type: GffType::List,
1188                life: FieldLife::Live,
1189                required: false,
1190                absent: AbsentDefault::Unverified,
1191                children: Some(SPAWN_POINT_LIST_CHILDREN),
1192                constraint: None,
1193            },
1194            FieldSchema {
1195                label: "AreaList",
1196                expected_type: GffType::List,
1197                life: FieldLife::Live,
1198                required: false,
1199                absent: AbsentDefault::Unverified,
1200                children: Some(AREA_LIST_CHILDREN),
1201                constraint: None,
1202            },
1203            FieldSchema {
1204                label: "SpawnList",
1205                expected_type: GffType::List,
1206                life: FieldLife::Live,
1207                required: false,
1208                absent: AbsentDefault::Unverified,
1209                children: Some(SPAWN_LIST_CHILDREN),
1210                constraint: None,
1211            },
1212            // --- Toolset-only fields ---
1213            FieldSchema {
1214                label: "TemplateResRef",
1215                expected_type: GffType::ResRef,
1216                life: FieldLife::Live,
1217                required: false,
1218                absent: AbsentDefault::Unverified,
1219                children: None,
1220                constraint: None,
1221            },
1222            FieldSchema {
1223                label: "Comment",
1224                expected_type: GffType::String,
1225                life: FieldLife::Live,
1226                required: false,
1227                absent: AbsentDefault::Unverified,
1228                children: None,
1229                constraint: None,
1230            },
1231            FieldSchema {
1232                label: "PaletteID",
1233                expected_type: GffType::UInt8,
1234                life: FieldLife::Live,
1235                required: false,
1236                absent: AbsentDefault::Unverified,
1237                children: None,
1238                constraint: None,
1239            },
1240        ];
1241        SCHEMA
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248
1249    /// Build a minimal UTE GFF for testing.
1250    fn make_test_ute_gff() -> Gff {
1251        let mut root = GffStruct::new(-1);
1252        root.push_field("Tag", GffValue::String("TestEncounter".into()));
1253        root.push_field("TemplateResRef", GffValue::resref_lit("enc_test001"));
1254        root.push_field(
1255            "LocalizedName",
1256            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(12345))),
1257        );
1258        root.push_field("Comment", GffValue::String("test comment".into()));
1259        root.push_field("PaletteID", GffValue::UInt8(3));
1260        root.push_field("Active", GffValue::UInt8(1));
1261        root.push_field("Reset", GffValue::UInt8(1));
1262        root.push_field("ResetTime", GffValue::Int32(120));
1263        root.push_field("Respawns", GffValue::Int32(-1));
1264        root.push_field("SpawnOption", GffValue::Int32(1));
1265        root.push_field("MaxCreatures", GffValue::Int32(4));
1266        root.push_field("RecCreatures", GffValue::Int32(2));
1267        root.push_field("PlayerOnly", GffValue::UInt8(0));
1268        root.push_field("Faction", GffValue::UInt32(1));
1269        root.push_field("DifficultyIndex", GffValue::Int32(3));
1270        root.push_field("Difficulty", GffValue::Int32(2));
1271        root.push_field("OnEntered", GffValue::resref_lit("k_enc_enter"));
1272        root.push_field("OnExit", GffValue::resref_lit("k_enc_exit"));
1273        root.push_field("OnHeartbeat", GffValue::resref_lit(""));
1274        root.push_field("OnExhausted", GffValue::resref_lit("k_enc_exhaust"));
1275        root.push_field("OnUserDefined", GffValue::resref_lit(""));
1276
1277        // Creature list with two entries.
1278        let mut c1 = GffStruct::new(0);
1279        c1.push_field("ResRef", GffValue::resref_lit("k_def_yourpaty"));
1280        c1.push_field("CR", GffValue::Single(3.0));
1281        c1.push_field("SingleSpawn", GffValue::UInt8(0));
1282
1283        let mut c2 = GffStruct::new(0);
1284        c2.push_field("ResRef", GffValue::resref_lit("k_def_darkjedi"));
1285        c2.push_field("CR", GffValue::Single(5.5));
1286        c2.push_field("SingleSpawn", GffValue::UInt8(1));
1287
1288        root.push_field("CreatureList", GffValue::List(vec![c1, c2]));
1289
1290        Gff::new(*b"UTE ", root)
1291    }
1292
1293    /// Build a fully-populated UTE GFF with all typed fields for roundtrip testing.
1294    fn make_full_ute_gff() -> Gff {
1295        let mut root = GffStruct::new(-1);
1296
1297        // Identity
1298        root.push_field("Tag", GffValue::String("FullEncounter".into()));
1299        root.push_field("TemplateResRef", GffValue::resref_lit("enc_full001"));
1300        root.push_field(
1301            "LocalizedName",
1302            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(99999))),
1303        );
1304        root.push_field("Comment", GffValue::String("full test".into()));
1305        root.push_field("PaletteID", GffValue::UInt8(7));
1306
1307        // Spawn config
1308        root.push_field("Active", GffValue::UInt8(1));
1309        root.push_field("Reset", GffValue::UInt8(0));
1310        root.push_field("ResetTime", GffValue::Int32(60));
1311        root.push_field("Respawns", GffValue::Int32(3));
1312        root.push_field("SpawnOption", GffValue::Int32(2));
1313        root.push_field("MaxCreatures", GffValue::Int32(6));
1314        root.push_field("RecCreatures", GffValue::Int32(3));
1315        root.push_field("PlayerOnly", GffValue::UInt8(1));
1316        root.push_field("Faction", GffValue::UInt32(42));
1317
1318        // Difficulty
1319        root.push_field("DifficultyIndex", GffValue::Int32(5));
1320        root.push_field("Difficulty", GffValue::Int32(4));
1321
1322        // Position
1323        root.push_field("XPosition", GffValue::Single(10.5));
1324        root.push_field("YPosition", GffValue::Single(20.25));
1325        root.push_field("ZPosition", GffValue::Single(-3.0));
1326
1327        // Scripts
1328        root.push_field("OnEntered", GffValue::resref_lit("s_enter"));
1329        root.push_field("OnExit", GffValue::resref_lit("s_exit"));
1330        root.push_field("OnHeartbeat", GffValue::resref_lit("s_hb"));
1331        root.push_field("OnExhausted", GffValue::resref_lit("s_exhaust"));
1332        root.push_field("OnUserDefined", GffValue::resref_lit("s_ud"));
1333
1334        // Runtime state
1335        root.push_field("NumberSpawned", GffValue::Int32(12));
1336        root.push_field("HeartbeatDay", GffValue::UInt32(100));
1337        root.push_field("HeartbeatTime", GffValue::UInt32(200));
1338        root.push_field("LastSpawnDay", GffValue::UInt32(101));
1339        root.push_field("LastSpawnTime", GffValue::UInt32(201));
1340        root.push_field("LastEntered", GffValue::UInt32(555));
1341        root.push_field("LastLeft", GffValue::UInt32(444));
1342        root.push_field("Started", GffValue::UInt8(1));
1343        root.push_field("Exhausted", GffValue::UInt8(0));
1344        root.push_field("CurrentSpawns", GffValue::Int32(3));
1345        root.push_field("CustomScriptId", GffValue::Int32(77));
1346
1347        // Area tracking
1348        root.push_field("AreaListMaxSize", GffValue::Int32(10));
1349        root.push_field("SpawnPoolActive", GffValue::Single(1.5));
1350        root.push_field("AreaPoints", GffValue::Single(2.75));
1351
1352        // Creature list
1353        let mut c1 = GffStruct::new(0);
1354        c1.push_field("ResRef", GffValue::resref_lit("cr_sith01"));
1355        c1.push_field("CR", GffValue::Single(4.0));
1356        c1.push_field("SingleSpawn", GffValue::UInt8(1));
1357        root.push_field("CreatureList", GffValue::List(vec![c1]));
1358
1359        // Geometry
1360        let mut g1 = GffStruct::new(0);
1361        g1.push_field("X", GffValue::Single(1.0));
1362        g1.push_field("Y", GffValue::Single(2.0));
1363        g1.push_field("Z", GffValue::Single(3.0));
1364        let mut g2 = GffStruct::new(0);
1365        g2.push_field("X", GffValue::Single(4.0));
1366        g2.push_field("Y", GffValue::Single(5.0));
1367        g2.push_field("Z", GffValue::Single(6.0));
1368        root.push_field("Geometry", GffValue::List(vec![g1, g2]));
1369
1370        // SpawnPointList
1371        let mut sp = GffStruct::new(0);
1372        sp.push_field("X", GffValue::Single(11.0));
1373        sp.push_field("Y", GffValue::Single(22.0));
1374        sp.push_field("Z", GffValue::Single(33.0));
1375        sp.push_field("Orientation", GffValue::Single(1.57));
1376        root.push_field("SpawnPointList", GffValue::List(vec![sp]));
1377
1378        // AreaList
1379        let mut ae = GffStruct::new(0);
1380        ae.push_field("AreaObject", GffValue::UInt32(9001));
1381        root.push_field("AreaList", GffValue::List(vec![ae]));
1382
1383        // SpawnList
1384        let mut se = GffStruct::new(0);
1385        se.push_field("SpawnResRef", GffValue::resref_lit("sp_ref01"));
1386        se.push_field("SpawnCR", GffValue::Single(2.5));
1387        root.push_field("SpawnList", GffValue::List(vec![se]));
1388
1389        Gff::new(*b"UTE ", root)
1390    }
1391
1392    #[test]
1393    fn reads_core_ute_fields() {
1394        let gff = make_test_ute_gff();
1395        let ute = Ute::from_gff(&gff).expect("must parse");
1396
1397        assert_eq!(ute.tag, "TestEncounter");
1398        assert_eq!(ute.template_resref, "enc_test001");
1399        assert_eq!(ute.name.string_ref.raw(), 12345);
1400        assert_eq!(ute.comment, "test comment");
1401        assert_eq!(ute.palette_id, 3);
1402        assert!(ute.active);
1403        assert!(ute.reset);
1404        assert_eq!(ute.reset_time, 120);
1405        assert_eq!(ute.respawns, -1);
1406        assert_eq!(ute.spawn_option, 1);
1407        assert_eq!(ute.max_creatures, 4);
1408        assert_eq!(ute.rec_creatures, 2);
1409        assert!(!ute.player_only);
1410        assert_eq!(ute.faction_id, 1);
1411        assert_eq!(ute.difficulty_index, 3);
1412        assert_eq!(ute.difficulty, 2);
1413        assert_eq!(ute.on_entered, "k_enc_enter");
1414        assert_eq!(ute.on_exit, "k_enc_exit");
1415        assert_eq!(ute.on_heartbeat, "");
1416        assert_eq!(ute.on_exhausted, "k_enc_exhaust");
1417        assert_eq!(ute.on_user_defined, "");
1418    }
1419
1420    #[test]
1421    fn reads_creature_list() {
1422        let gff = make_test_ute_gff();
1423        let ute = Ute::from_gff(&gff).expect("must parse");
1424
1425        assert_eq!(ute.creatures.len(), 2);
1426        assert_eq!(ute.creatures[0].resref, "k_def_yourpaty");
1427        assert_eq!(ute.creatures[0].cr, 3.0);
1428        assert!(!ute.creatures[0].single_spawn);
1429        assert_eq!(ute.creatures[1].resref, "k_def_darkjedi");
1430        assert_eq!(ute.creatures[1].cr, 5.5);
1431        assert!(ute.creatures[1].single_spawn);
1432    }
1433
1434    #[test]
1435    fn all_fields_survive_typed_roundtrip() {
1436        let gff = make_full_ute_gff();
1437        let ute = Ute::from_gff(&gff).expect("must parse full GFF");
1438
1439        // Verify all scalar fields read correctly.
1440        assert_eq!(ute.tag, "FullEncounter");
1441        assert_eq!(ute.template_resref, "enc_full001");
1442        assert_eq!(ute.name.string_ref.raw(), 99999);
1443        assert_eq!(ute.comment, "full test");
1444        assert_eq!(ute.palette_id, 7);
1445        assert!(ute.active);
1446        assert!(!ute.reset);
1447        assert_eq!(ute.reset_time, 60);
1448        assert_eq!(ute.respawns, 3);
1449        assert_eq!(ute.spawn_option, 2);
1450        assert_eq!(ute.max_creatures, 6);
1451        assert_eq!(ute.rec_creatures, 3);
1452        assert!(ute.player_only);
1453        assert_eq!(ute.faction_id, 42);
1454        assert_eq!(ute.difficulty_index, 5);
1455        assert_eq!(ute.difficulty, 4);
1456        assert_eq!(ute.x_position, 10.5);
1457        assert_eq!(ute.y_position, 20.25);
1458        assert_eq!(ute.z_position, -3.0);
1459        assert_eq!(ute.on_entered, "s_enter");
1460        assert_eq!(ute.on_exit, "s_exit");
1461        assert_eq!(ute.on_heartbeat, "s_hb");
1462        assert_eq!(ute.on_exhausted, "s_exhaust");
1463        assert_eq!(ute.on_user_defined, "s_ud");
1464        assert_eq!(ute.number_spawned, 12);
1465        assert_eq!(ute.heartbeat_day, 100);
1466        assert_eq!(ute.heartbeat_time, 200);
1467        assert_eq!(ute.last_spawn_day, 101);
1468        assert_eq!(ute.last_spawn_time, 201);
1469        assert_eq!(ute.last_entered, 555);
1470        assert_eq!(ute.last_left, 444);
1471        assert!(ute.started);
1472        assert!(!ute.exhausted);
1473        assert_eq!(ute.current_spawns, 3);
1474        assert_eq!(ute.custom_script_id, 77);
1475        assert_eq!(ute.area_list_max_size, 10);
1476        assert_eq!(ute.spawn_pool_active, 1.5);
1477        assert_eq!(ute.area_points, 2.75);
1478
1479        // Verify lists.
1480        assert_eq!(ute.creatures.len(), 1);
1481        assert_eq!(ute.creatures[0].resref, "cr_sith01");
1482        assert_eq!(ute.creatures[0].cr, 4.0);
1483        assert!(ute.creatures[0].single_spawn);
1484
1485        assert_eq!(ute.geometry.len(), 2);
1486        assert_eq!(ute.geometry[0].x, 1.0);
1487        assert_eq!(ute.geometry[1].y, 5.0);
1488
1489        assert_eq!(ute.spawn_points.len(), 1);
1490        assert_eq!(ute.spawn_points[0].x, 11.0);
1491        assert_eq!(ute.spawn_points[0].orientation, 1.57);
1492
1493        assert_eq!(ute.area_list.len(), 1);
1494        assert_eq!(ute.area_list[0].area_object, 9001);
1495
1496        assert_eq!(ute.spawn_list.len(), 1);
1497        assert_eq!(ute.spawn_list[0].spawn_resref, "sp_ref01");
1498        assert_eq!(ute.spawn_list[0].spawn_cr, 2.5);
1499
1500        // Write and re-read - all typed fields must survive.
1501        let bytes = write_ute_to_vec(&ute).expect("write succeeds");
1502        let reparsed = read_ute_from_bytes(&bytes).expect("reparse succeeds");
1503        assert_eq!(ute, reparsed);
1504    }
1505
1506    #[test]
1507    fn typed_edits_roundtrip_through_gff_writer() {
1508        let gff = make_test_ute_gff();
1509        let mut ute = Ute::from_gff(&gff).expect("must parse");
1510        ute.tag = "EditedEncounter".into();
1511        ute.max_creatures = 8;
1512        ute.on_entered = ResRef::new("rust_on_enter").expect("valid test resref");
1513        ute.creatures[0].resref = ResRef::new("k_new_creature").expect("valid test resref");
1514
1515        let bytes = write_ute_to_vec(&ute).expect("write succeeds");
1516        let reparsed = read_ute_from_bytes(&bytes).expect("reparse succeeds");
1517
1518        assert_eq!(reparsed.tag, "EditedEncounter");
1519        assert_eq!(reparsed.max_creatures, 8);
1520        assert_eq!(reparsed.on_entered, "rust_on_enter");
1521        assert_eq!(reparsed.creatures[0].resref, "k_new_creature");
1522    }
1523
1524    #[test]
1525    fn read_ute_from_reader_matches_bytes_path() {
1526        let gff = make_test_ute_gff();
1527        let bytes = {
1528            let mut c = Cursor::new(Vec::new());
1529            write_gff(&mut c, &gff).expect("test fixture must be valid");
1530            c.into_inner()
1531        };
1532
1533        let mut cursor = Cursor::new(&bytes);
1534        let via_reader = read_ute(&mut cursor).expect("reader parse succeeds");
1535        let via_bytes = read_ute_from_bytes(&bytes).expect("bytes parse succeeds");
1536
1537        assert_eq!(via_reader, via_bytes);
1538    }
1539
1540    #[test]
1541    fn rejects_non_ute_file_type() {
1542        let mut gff = make_test_ute_gff();
1543        gff.file_type = *b"UTT ";
1544
1545        let err = Ute::from_gff(&gff).expect_err("UTT must be rejected as UTE input");
1546        assert!(matches!(
1547            err,
1548            UteError::UnsupportedFileType(file_type) if file_type == *b"UTT "
1549        ));
1550    }
1551
1552    #[test]
1553    fn type_mismatch_on_localized_name_is_error() {
1554        let mut gff = make_test_ute_gff();
1555        gff.root
1556            .fields
1557            .retain(|field| field.label != "LocalizedName");
1558        gff.root.push_field("LocalizedName", GffValue::UInt32(5));
1559
1560        let err = Ute::from_gff(&gff).expect_err("type mismatch must be rejected");
1561        assert!(matches!(
1562            err,
1563            UteError::TypeMismatch {
1564                field: "LocalizedName",
1565                expected: "LocalizedString",
1566            }
1567        ));
1568    }
1569
1570    #[test]
1571    fn write_ute_matches_direct_gff_writer() {
1572        let gff = make_test_ute_gff();
1573        let ute = Ute::from_gff(&gff).expect("must parse");
1574
1575        let via_typed = write_ute_to_vec(&ute).expect("typed write succeeds");
1576
1577        let mut direct = Cursor::new(Vec::new());
1578        write_gff(&mut direct, &ute.to_gff()).expect("direct write succeeds");
1579
1580        assert_eq!(via_typed, direct.into_inner());
1581    }
1582
1583    #[test]
1584    fn empty_creature_list_ok() {
1585        let mut gff = make_test_ute_gff();
1586        gff.root.fields.retain(|f| f.label != "CreatureList");
1587
1588        let ute = Ute::from_gff(&gff).expect("must parse");
1589        assert!(ute.creatures.is_empty());
1590    }
1591
1592    #[test]
1593    fn schema_field_count() {
1594        assert_eq!(Ute::schema().len(), 43);
1595    }
1596
1597    #[test]
1598    fn schema_no_duplicate_labels() {
1599        let schema = Ute::schema();
1600        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1601        labels.sort();
1602        let before = labels.len();
1603        labels.dedup();
1604        assert_eq!(before, labels.len(), "duplicate labels in UTE schema");
1605    }
1606
1607    #[test]
1608    fn schema_lists_have_children() {
1609        let schema = Ute::schema();
1610        let geometry = schema
1611            .iter()
1612            .find(|f| f.label == "Geometry")
1613            .expect("test fixture must be valid");
1614        assert_eq!(
1615            geometry.children.expect("test fixture must be valid").len(),
1616            3
1617        );
1618        let creatures = schema
1619            .iter()
1620            .find(|f| f.label == "CreatureList")
1621            .expect("test fixture must be valid");
1622        assert_eq!(
1623            creatures
1624                .children
1625                .expect("test fixture must be valid")
1626                .len(),
1627            3
1628        );
1629        let spawn_points = schema
1630            .iter()
1631            .find(|f| f.label == "SpawnPointList")
1632            .expect("test fixture must be valid");
1633        assert_eq!(
1634            spawn_points
1635                .children
1636                .expect("test fixture must be valid")
1637                .len(),
1638            4
1639        );
1640        let area_list = schema
1641            .iter()
1642            .find(|f| f.label == "AreaList")
1643            .expect("test fixture must be valid");
1644        assert_eq!(
1645            area_list
1646                .children
1647                .expect("test fixture must be valid")
1648                .len(),
1649            1
1650        );
1651        let spawn_list = schema
1652            .iter()
1653            .find(|f| f.label == "SpawnList")
1654            .expect("test fixture must be valid");
1655        assert_eq!(
1656            spawn_list
1657                .children
1658                .expect("test fixture must be valid")
1659                .len(),
1660            2
1661        );
1662    }
1663}