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::{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`).
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`).
222    pub comment: String,
223    /// Palette ID (`PaletteID`).
224    pub palette_id: u8,
225
226    // --- Spawn configuration ---
227    /// Whether the encounter is active (`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`).
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`).
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        required: false,
712        children: None,
713        constraint: None,
714    },
715    FieldSchema {
716        label: "Y",
717        expected_type: GffType::Single,
718        required: false,
719        children: None,
720        constraint: None,
721    },
722    FieldSchema {
723        label: "Z",
724        expected_type: GffType::Single,
725        required: false,
726        children: None,
727        constraint: None,
728    },
729];
730
731/// UTE `CreatureList` list entry child schema.
732static CREATURE_LIST_CHILDREN: &[FieldSchema] = &[
733    FieldSchema {
734        label: "ResRef",
735        expected_type: GffType::ResRef,
736        required: false,
737        children: None,
738        constraint: None,
739    },
740    FieldSchema {
741        label: "CR",
742        expected_type: GffType::Single,
743        required: false,
744        children: None,
745        constraint: None,
746    },
747    FieldSchema {
748        label: "SingleSpawn",
749        expected_type: GffType::UInt8,
750        required: false,
751        children: None,
752        constraint: None,
753    },
754];
755
756/// UTE `SpawnPointList` list entry child schema.
757static SPAWN_POINT_LIST_CHILDREN: &[FieldSchema] = &[
758    FieldSchema {
759        label: "X",
760        expected_type: GffType::Single,
761        required: false,
762        children: None,
763        constraint: None,
764    },
765    FieldSchema {
766        label: "Y",
767        expected_type: GffType::Single,
768        required: false,
769        children: None,
770        constraint: None,
771    },
772    FieldSchema {
773        label: "Z",
774        expected_type: GffType::Single,
775        required: false,
776        children: None,
777        constraint: None,
778    },
779    FieldSchema {
780        label: "Orientation",
781        expected_type: GffType::Single,
782        required: false,
783        children: None,
784        constraint: None,
785    },
786];
787
788/// UTE `AreaList` list entry child schema.
789static AREA_LIST_CHILDREN: &[FieldSchema] = &[FieldSchema {
790    label: "AreaObject",
791    expected_type: GffType::UInt32,
792    required: false,
793    children: None,
794    constraint: None,
795}];
796
797/// UTE `SpawnList` list entry child schema.
798static SPAWN_LIST_CHILDREN: &[FieldSchema] = &[
799    FieldSchema {
800        label: "SpawnResRef",
801        expected_type: GffType::ResRef,
802        required: false,
803        children: None,
804        constraint: None,
805    },
806    FieldSchema {
807        label: "SpawnCR",
808        expected_type: GffType::Single,
809        required: false,
810        children: None,
811        constraint: None,
812    },
813];
814
815impl GffSchema for Ute {
816    fn schema() -> &'static [FieldSchema] {
817        static SCHEMA: &[FieldSchema] = &[
818            // --- Identity ---
819            FieldSchema {
820                label: "Tag",
821                expected_type: GffType::String,
822                required: false,
823                children: None,
824                constraint: None,
825            },
826            FieldSchema {
827                label: "LocalizedName",
828                expected_type: GffType::LocalizedString,
829                required: false,
830                children: None,
831                constraint: None,
832            },
833            // --- Spawn configuration ---
834            FieldSchema {
835                label: "Active",
836                expected_type: GffType::UInt8,
837                required: false,
838                children: None,
839                constraint: None,
840            },
841            FieldSchema {
842                label: "Reset",
843                expected_type: GffType::UInt8,
844                required: false,
845                children: None,
846                constraint: None,
847            },
848            FieldSchema {
849                label: "ResetTime",
850                expected_type: GffType::Int32,
851                required: false,
852                children: None,
853                constraint: None,
854            },
855            FieldSchema {
856                label: "Respawns",
857                expected_type: GffType::Int32,
858                required: false,
859                children: None,
860                constraint: None,
861            },
862            FieldSchema {
863                label: "SpawnOption",
864                expected_type: GffType::Int32,
865                required: false,
866                children: None,
867                constraint: None,
868            },
869            FieldSchema {
870                label: "MaxCreatures",
871                expected_type: GffType::Int32,
872                required: false,
873                children: None,
874                constraint: None,
875            },
876            FieldSchema {
877                label: "RecCreatures",
878                expected_type: GffType::Int32,
879                required: false,
880                children: None,
881                constraint: None,
882            },
883            FieldSchema {
884                label: "PlayerOnly",
885                expected_type: GffType::UInt8,
886                required: false,
887                children: None,
888                constraint: None,
889            },
890            FieldSchema {
891                label: "Faction",
892                expected_type: GffType::UInt32,
893                required: false,
894                children: None,
895                constraint: None,
896            },
897            // --- Difficulty ---
898            FieldSchema {
899                label: "DifficultyIndex",
900                expected_type: GffType::Int32,
901                required: false,
902                children: None,
903                constraint: None,
904            },
905            FieldSchema {
906                label: "Difficulty",
907                expected_type: GffType::Int32,
908                required: false,
909                children: None,
910                constraint: None,
911            },
912            // --- Position ---
913            FieldSchema {
914                label: "XPosition",
915                expected_type: GffType::Single,
916                required: false,
917                children: None,
918                constraint: None,
919            },
920            FieldSchema {
921                label: "YPosition",
922                expected_type: GffType::Single,
923                required: false,
924                children: None,
925                constraint: None,
926            },
927            FieldSchema {
928                label: "ZPosition",
929                expected_type: GffType::Single,
930                required: false,
931                children: None,
932                constraint: None,
933            },
934            // --- Scripts (5) ---
935            FieldSchema {
936                label: "OnEntered",
937                expected_type: GffType::ResRef,
938                required: false,
939                children: None,
940                constraint: None,
941            },
942            FieldSchema {
943                label: "OnExit",
944                expected_type: GffType::ResRef,
945                required: false,
946                children: None,
947                constraint: None,
948            },
949            FieldSchema {
950                label: "OnHeartbeat",
951                expected_type: GffType::ResRef,
952                required: false,
953                children: None,
954                constraint: None,
955            },
956            FieldSchema {
957                label: "OnExhausted",
958                expected_type: GffType::ResRef,
959                required: false,
960                children: None,
961                constraint: None,
962            },
963            FieldSchema {
964                label: "OnUserDefined",
965                expected_type: GffType::ResRef,
966                required: false,
967                children: None,
968                constraint: None,
969            },
970            // --- Runtime state ---
971            FieldSchema {
972                label: "NumberSpawned",
973                expected_type: GffType::Int32,
974                required: false,
975                children: None,
976                constraint: None,
977            },
978            FieldSchema {
979                label: "HeartbeatDay",
980                expected_type: GffType::UInt32,
981                required: false,
982                children: None,
983                constraint: None,
984            },
985            FieldSchema {
986                label: "HeartbeatTime",
987                expected_type: GffType::UInt32,
988                required: false,
989                children: None,
990                constraint: None,
991            },
992            FieldSchema {
993                label: "LastSpawnDay",
994                expected_type: GffType::UInt32,
995                required: false,
996                children: None,
997                constraint: None,
998            },
999            FieldSchema {
1000                label: "LastSpawnTime",
1001                expected_type: GffType::UInt32,
1002                required: false,
1003                children: None,
1004                constraint: None,
1005            },
1006            FieldSchema {
1007                label: "LastEntered",
1008                expected_type: GffType::UInt32,
1009                required: false,
1010                children: None,
1011                constraint: None,
1012            },
1013            FieldSchema {
1014                label: "LastLeft",
1015                expected_type: GffType::UInt32,
1016                required: false,
1017                children: None,
1018                constraint: None,
1019            },
1020            FieldSchema {
1021                label: "Started",
1022                expected_type: GffType::UInt8,
1023                required: false,
1024                children: None,
1025                constraint: None,
1026            },
1027            FieldSchema {
1028                label: "Exhausted",
1029                expected_type: GffType::UInt8,
1030                required: false,
1031                children: None,
1032                constraint: None,
1033            },
1034            FieldSchema {
1035                label: "CurrentSpawns",
1036                expected_type: GffType::Int32,
1037                required: false,
1038                children: None,
1039                constraint: None,
1040            },
1041            FieldSchema {
1042                label: "CustomScriptId",
1043                expected_type: GffType::Int32,
1044                required: false,
1045                children: None,
1046                constraint: None,
1047            },
1048            // --- Area tracking ---
1049            FieldSchema {
1050                label: "AreaListMaxSize",
1051                expected_type: GffType::Int32,
1052                required: false,
1053                children: None,
1054                constraint: None,
1055            },
1056            FieldSchema {
1057                label: "SpawnPoolActive",
1058                expected_type: GffType::Single,
1059                required: false,
1060                children: None,
1061                constraint: None,
1062            },
1063            FieldSchema {
1064                label: "AreaPoints",
1065                expected_type: GffType::Single,
1066                required: false,
1067                children: None,
1068                constraint: None,
1069            },
1070            // --- Engine-read lists ---
1071            FieldSchema {
1072                label: "Geometry",
1073                expected_type: GffType::List,
1074                required: false,
1075                children: Some(GEOMETRY_CHILDREN),
1076                constraint: None,
1077            },
1078            FieldSchema {
1079                label: "CreatureList",
1080                expected_type: GffType::List,
1081                required: false,
1082                children: Some(CREATURE_LIST_CHILDREN),
1083                constraint: None,
1084            },
1085            FieldSchema {
1086                label: "SpawnPointList",
1087                expected_type: GffType::List,
1088                required: false,
1089                children: Some(SPAWN_POINT_LIST_CHILDREN),
1090                constraint: None,
1091            },
1092            FieldSchema {
1093                label: "AreaList",
1094                expected_type: GffType::List,
1095                required: false,
1096                children: Some(AREA_LIST_CHILDREN),
1097                constraint: None,
1098            },
1099            FieldSchema {
1100                label: "SpawnList",
1101                expected_type: GffType::List,
1102                required: false,
1103                children: Some(SPAWN_LIST_CHILDREN),
1104                constraint: None,
1105            },
1106            // --- Toolset-only fields ---
1107            FieldSchema {
1108                label: "TemplateResRef",
1109                expected_type: GffType::ResRef,
1110                required: false,
1111                children: None,
1112                constraint: None,
1113            },
1114            FieldSchema {
1115                label: "Comment",
1116                expected_type: GffType::String,
1117                required: false,
1118                children: None,
1119                constraint: None,
1120            },
1121            FieldSchema {
1122                label: "PaletteID",
1123                expected_type: GffType::UInt8,
1124                required: false,
1125                children: None,
1126                constraint: None,
1127            },
1128        ];
1129        SCHEMA
1130    }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136
1137    /// Build a minimal UTE GFF for testing.
1138    fn make_test_ute_gff() -> Gff {
1139        let mut root = GffStruct::new(-1);
1140        root.push_field("Tag", GffValue::String("TestEncounter".into()));
1141        root.push_field("TemplateResRef", GffValue::resref_lit("enc_test001"));
1142        root.push_field(
1143            "LocalizedName",
1144            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(12345))),
1145        );
1146        root.push_field("Comment", GffValue::String("test comment".into()));
1147        root.push_field("PaletteID", GffValue::UInt8(3));
1148        root.push_field("Active", GffValue::UInt8(1));
1149        root.push_field("Reset", GffValue::UInt8(1));
1150        root.push_field("ResetTime", GffValue::Int32(120));
1151        root.push_field("Respawns", GffValue::Int32(-1));
1152        root.push_field("SpawnOption", GffValue::Int32(1));
1153        root.push_field("MaxCreatures", GffValue::Int32(4));
1154        root.push_field("RecCreatures", GffValue::Int32(2));
1155        root.push_field("PlayerOnly", GffValue::UInt8(0));
1156        root.push_field("Faction", GffValue::UInt32(1));
1157        root.push_field("DifficultyIndex", GffValue::Int32(3));
1158        root.push_field("Difficulty", GffValue::Int32(2));
1159        root.push_field("OnEntered", GffValue::resref_lit("k_enc_enter"));
1160        root.push_field("OnExit", GffValue::resref_lit("k_enc_exit"));
1161        root.push_field("OnHeartbeat", GffValue::resref_lit(""));
1162        root.push_field("OnExhausted", GffValue::resref_lit("k_enc_exhaust"));
1163        root.push_field("OnUserDefined", GffValue::resref_lit(""));
1164
1165        // Creature list with two entries.
1166        let mut c1 = GffStruct::new(0);
1167        c1.push_field("ResRef", GffValue::resref_lit("k_def_yourpaty"));
1168        c1.push_field("CR", GffValue::Single(3.0));
1169        c1.push_field("SingleSpawn", GffValue::UInt8(0));
1170
1171        let mut c2 = GffStruct::new(0);
1172        c2.push_field("ResRef", GffValue::resref_lit("k_def_darkjedi"));
1173        c2.push_field("CR", GffValue::Single(5.5));
1174        c2.push_field("SingleSpawn", GffValue::UInt8(1));
1175
1176        root.push_field("CreatureList", GffValue::List(vec![c1, c2]));
1177
1178        Gff::new(*b"UTE ", root)
1179    }
1180
1181    /// Build a fully-populated UTE GFF with all typed fields for roundtrip testing.
1182    fn make_full_ute_gff() -> Gff {
1183        let mut root = GffStruct::new(-1);
1184
1185        // Identity
1186        root.push_field("Tag", GffValue::String("FullEncounter".into()));
1187        root.push_field("TemplateResRef", GffValue::resref_lit("enc_full001"));
1188        root.push_field(
1189            "LocalizedName",
1190            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(99999))),
1191        );
1192        root.push_field("Comment", GffValue::String("full test".into()));
1193        root.push_field("PaletteID", GffValue::UInt8(7));
1194
1195        // Spawn config
1196        root.push_field("Active", GffValue::UInt8(1));
1197        root.push_field("Reset", GffValue::UInt8(0));
1198        root.push_field("ResetTime", GffValue::Int32(60));
1199        root.push_field("Respawns", GffValue::Int32(3));
1200        root.push_field("SpawnOption", GffValue::Int32(2));
1201        root.push_field("MaxCreatures", GffValue::Int32(6));
1202        root.push_field("RecCreatures", GffValue::Int32(3));
1203        root.push_field("PlayerOnly", GffValue::UInt8(1));
1204        root.push_field("Faction", GffValue::UInt32(42));
1205
1206        // Difficulty
1207        root.push_field("DifficultyIndex", GffValue::Int32(5));
1208        root.push_field("Difficulty", GffValue::Int32(4));
1209
1210        // Position
1211        root.push_field("XPosition", GffValue::Single(10.5));
1212        root.push_field("YPosition", GffValue::Single(20.25));
1213        root.push_field("ZPosition", GffValue::Single(-3.0));
1214
1215        // Scripts
1216        root.push_field("OnEntered", GffValue::resref_lit("s_enter"));
1217        root.push_field("OnExit", GffValue::resref_lit("s_exit"));
1218        root.push_field("OnHeartbeat", GffValue::resref_lit("s_hb"));
1219        root.push_field("OnExhausted", GffValue::resref_lit("s_exhaust"));
1220        root.push_field("OnUserDefined", GffValue::resref_lit("s_ud"));
1221
1222        // Runtime state
1223        root.push_field("NumberSpawned", GffValue::Int32(12));
1224        root.push_field("HeartbeatDay", GffValue::UInt32(100));
1225        root.push_field("HeartbeatTime", GffValue::UInt32(200));
1226        root.push_field("LastSpawnDay", GffValue::UInt32(101));
1227        root.push_field("LastSpawnTime", GffValue::UInt32(201));
1228        root.push_field("LastEntered", GffValue::UInt32(555));
1229        root.push_field("LastLeft", GffValue::UInt32(444));
1230        root.push_field("Started", GffValue::UInt8(1));
1231        root.push_field("Exhausted", GffValue::UInt8(0));
1232        root.push_field("CurrentSpawns", GffValue::Int32(3));
1233        root.push_field("CustomScriptId", GffValue::Int32(77));
1234
1235        // Area tracking
1236        root.push_field("AreaListMaxSize", GffValue::Int32(10));
1237        root.push_field("SpawnPoolActive", GffValue::Single(1.5));
1238        root.push_field("AreaPoints", GffValue::Single(2.75));
1239
1240        // Creature list
1241        let mut c1 = GffStruct::new(0);
1242        c1.push_field("ResRef", GffValue::resref_lit("cr_sith01"));
1243        c1.push_field("CR", GffValue::Single(4.0));
1244        c1.push_field("SingleSpawn", GffValue::UInt8(1));
1245        root.push_field("CreatureList", GffValue::List(vec![c1]));
1246
1247        // Geometry
1248        let mut g1 = GffStruct::new(0);
1249        g1.push_field("X", GffValue::Single(1.0));
1250        g1.push_field("Y", GffValue::Single(2.0));
1251        g1.push_field("Z", GffValue::Single(3.0));
1252        let mut g2 = GffStruct::new(0);
1253        g2.push_field("X", GffValue::Single(4.0));
1254        g2.push_field("Y", GffValue::Single(5.0));
1255        g2.push_field("Z", GffValue::Single(6.0));
1256        root.push_field("Geometry", GffValue::List(vec![g1, g2]));
1257
1258        // SpawnPointList
1259        let mut sp = GffStruct::new(0);
1260        sp.push_field("X", GffValue::Single(11.0));
1261        sp.push_field("Y", GffValue::Single(22.0));
1262        sp.push_field("Z", GffValue::Single(33.0));
1263        sp.push_field("Orientation", GffValue::Single(1.57));
1264        root.push_field("SpawnPointList", GffValue::List(vec![sp]));
1265
1266        // AreaList
1267        let mut ae = GffStruct::new(0);
1268        ae.push_field("AreaObject", GffValue::UInt32(9001));
1269        root.push_field("AreaList", GffValue::List(vec![ae]));
1270
1271        // SpawnList
1272        let mut se = GffStruct::new(0);
1273        se.push_field("SpawnResRef", GffValue::resref_lit("sp_ref01"));
1274        se.push_field("SpawnCR", GffValue::Single(2.5));
1275        root.push_field("SpawnList", GffValue::List(vec![se]));
1276
1277        Gff::new(*b"UTE ", root)
1278    }
1279
1280    #[test]
1281    fn reads_core_ute_fields() {
1282        let gff = make_test_ute_gff();
1283        let ute = Ute::from_gff(&gff).expect("must parse");
1284
1285        assert_eq!(ute.tag, "TestEncounter");
1286        assert_eq!(ute.template_resref, "enc_test001");
1287        assert_eq!(ute.name.string_ref.raw(), 12345);
1288        assert_eq!(ute.comment, "test comment");
1289        assert_eq!(ute.palette_id, 3);
1290        assert!(ute.active);
1291        assert!(ute.reset);
1292        assert_eq!(ute.reset_time, 120);
1293        assert_eq!(ute.respawns, -1);
1294        assert_eq!(ute.spawn_option, 1);
1295        assert_eq!(ute.max_creatures, 4);
1296        assert_eq!(ute.rec_creatures, 2);
1297        assert!(!ute.player_only);
1298        assert_eq!(ute.faction_id, 1);
1299        assert_eq!(ute.difficulty_index, 3);
1300        assert_eq!(ute.difficulty, 2);
1301        assert_eq!(ute.on_entered, "k_enc_enter");
1302        assert_eq!(ute.on_exit, "k_enc_exit");
1303        assert_eq!(ute.on_heartbeat, "");
1304        assert_eq!(ute.on_exhausted, "k_enc_exhaust");
1305        assert_eq!(ute.on_user_defined, "");
1306    }
1307
1308    #[test]
1309    fn reads_creature_list() {
1310        let gff = make_test_ute_gff();
1311        let ute = Ute::from_gff(&gff).expect("must parse");
1312
1313        assert_eq!(ute.creatures.len(), 2);
1314        assert_eq!(ute.creatures[0].resref, "k_def_yourpaty");
1315        assert_eq!(ute.creatures[0].cr, 3.0);
1316        assert!(!ute.creatures[0].single_spawn);
1317        assert_eq!(ute.creatures[1].resref, "k_def_darkjedi");
1318        assert_eq!(ute.creatures[1].cr, 5.5);
1319        assert!(ute.creatures[1].single_spawn);
1320    }
1321
1322    #[test]
1323    fn all_fields_survive_typed_roundtrip() {
1324        let gff = make_full_ute_gff();
1325        let ute = Ute::from_gff(&gff).expect("must parse full GFF");
1326
1327        // Verify all scalar fields read correctly.
1328        assert_eq!(ute.tag, "FullEncounter");
1329        assert_eq!(ute.template_resref, "enc_full001");
1330        assert_eq!(ute.name.string_ref.raw(), 99999);
1331        assert_eq!(ute.comment, "full test");
1332        assert_eq!(ute.palette_id, 7);
1333        assert!(ute.active);
1334        assert!(!ute.reset);
1335        assert_eq!(ute.reset_time, 60);
1336        assert_eq!(ute.respawns, 3);
1337        assert_eq!(ute.spawn_option, 2);
1338        assert_eq!(ute.max_creatures, 6);
1339        assert_eq!(ute.rec_creatures, 3);
1340        assert!(ute.player_only);
1341        assert_eq!(ute.faction_id, 42);
1342        assert_eq!(ute.difficulty_index, 5);
1343        assert_eq!(ute.difficulty, 4);
1344        assert_eq!(ute.x_position, 10.5);
1345        assert_eq!(ute.y_position, 20.25);
1346        assert_eq!(ute.z_position, -3.0);
1347        assert_eq!(ute.on_entered, "s_enter");
1348        assert_eq!(ute.on_exit, "s_exit");
1349        assert_eq!(ute.on_heartbeat, "s_hb");
1350        assert_eq!(ute.on_exhausted, "s_exhaust");
1351        assert_eq!(ute.on_user_defined, "s_ud");
1352        assert_eq!(ute.number_spawned, 12);
1353        assert_eq!(ute.heartbeat_day, 100);
1354        assert_eq!(ute.heartbeat_time, 200);
1355        assert_eq!(ute.last_spawn_day, 101);
1356        assert_eq!(ute.last_spawn_time, 201);
1357        assert_eq!(ute.last_entered, 555);
1358        assert_eq!(ute.last_left, 444);
1359        assert!(ute.started);
1360        assert!(!ute.exhausted);
1361        assert_eq!(ute.current_spawns, 3);
1362        assert_eq!(ute.custom_script_id, 77);
1363        assert_eq!(ute.area_list_max_size, 10);
1364        assert_eq!(ute.spawn_pool_active, 1.5);
1365        assert_eq!(ute.area_points, 2.75);
1366
1367        // Verify lists.
1368        assert_eq!(ute.creatures.len(), 1);
1369        assert_eq!(ute.creatures[0].resref, "cr_sith01");
1370        assert_eq!(ute.creatures[0].cr, 4.0);
1371        assert!(ute.creatures[0].single_spawn);
1372
1373        assert_eq!(ute.geometry.len(), 2);
1374        assert_eq!(ute.geometry[0].x, 1.0);
1375        assert_eq!(ute.geometry[1].y, 5.0);
1376
1377        assert_eq!(ute.spawn_points.len(), 1);
1378        assert_eq!(ute.spawn_points[0].x, 11.0);
1379        assert_eq!(ute.spawn_points[0].orientation, 1.57);
1380
1381        assert_eq!(ute.area_list.len(), 1);
1382        assert_eq!(ute.area_list[0].area_object, 9001);
1383
1384        assert_eq!(ute.spawn_list.len(), 1);
1385        assert_eq!(ute.spawn_list[0].spawn_resref, "sp_ref01");
1386        assert_eq!(ute.spawn_list[0].spawn_cr, 2.5);
1387
1388        // Write and re-read - all typed fields must survive.
1389        let bytes = write_ute_to_vec(&ute).expect("write succeeds");
1390        let reparsed = read_ute_from_bytes(&bytes).expect("reparse succeeds");
1391        assert_eq!(ute, reparsed);
1392    }
1393
1394    #[test]
1395    fn typed_edits_roundtrip_through_gff_writer() {
1396        let gff = make_test_ute_gff();
1397        let mut ute = Ute::from_gff(&gff).expect("must parse");
1398        ute.tag = "EditedEncounter".into();
1399        ute.max_creatures = 8;
1400        ute.on_entered = ResRef::new("rust_on_enter").expect("valid test resref");
1401        ute.creatures[0].resref = ResRef::new("k_new_creature").expect("valid test resref");
1402
1403        let bytes = write_ute_to_vec(&ute).expect("write succeeds");
1404        let reparsed = read_ute_from_bytes(&bytes).expect("reparse succeeds");
1405
1406        assert_eq!(reparsed.tag, "EditedEncounter");
1407        assert_eq!(reparsed.max_creatures, 8);
1408        assert_eq!(reparsed.on_entered, "rust_on_enter");
1409        assert_eq!(reparsed.creatures[0].resref, "k_new_creature");
1410    }
1411
1412    #[test]
1413    fn read_ute_from_reader_matches_bytes_path() {
1414        let gff = make_test_ute_gff();
1415        let bytes = {
1416            let mut c = Cursor::new(Vec::new());
1417            write_gff(&mut c, &gff).expect("test fixture must be valid");
1418            c.into_inner()
1419        };
1420
1421        let mut cursor = Cursor::new(&bytes);
1422        let via_reader = read_ute(&mut cursor).expect("reader parse succeeds");
1423        let via_bytes = read_ute_from_bytes(&bytes).expect("bytes parse succeeds");
1424
1425        assert_eq!(via_reader, via_bytes);
1426    }
1427
1428    #[test]
1429    fn rejects_non_ute_file_type() {
1430        let mut gff = make_test_ute_gff();
1431        gff.file_type = *b"UTT ";
1432
1433        let err = Ute::from_gff(&gff).expect_err("UTT must be rejected as UTE input");
1434        assert!(matches!(
1435            err,
1436            UteError::UnsupportedFileType(file_type) if file_type == *b"UTT "
1437        ));
1438    }
1439
1440    #[test]
1441    fn type_mismatch_on_localized_name_is_error() {
1442        let mut gff = make_test_ute_gff();
1443        gff.root
1444            .fields
1445            .retain(|field| field.label != "LocalizedName");
1446        gff.root.push_field("LocalizedName", GffValue::UInt32(5));
1447
1448        let err = Ute::from_gff(&gff).expect_err("type mismatch must be rejected");
1449        assert!(matches!(
1450            err,
1451            UteError::TypeMismatch {
1452                field: "LocalizedName",
1453                expected: "LocalizedString",
1454            }
1455        ));
1456    }
1457
1458    #[test]
1459    fn write_ute_matches_direct_gff_writer() {
1460        let gff = make_test_ute_gff();
1461        let ute = Ute::from_gff(&gff).expect("must parse");
1462
1463        let via_typed = write_ute_to_vec(&ute).expect("typed write succeeds");
1464
1465        let mut direct = Cursor::new(Vec::new());
1466        write_gff(&mut direct, &ute.to_gff()).expect("direct write succeeds");
1467
1468        assert_eq!(via_typed, direct.into_inner());
1469    }
1470
1471    #[test]
1472    fn empty_creature_list_ok() {
1473        let mut gff = make_test_ute_gff();
1474        gff.root.fields.retain(|f| f.label != "CreatureList");
1475
1476        let ute = Ute::from_gff(&gff).expect("must parse");
1477        assert!(ute.creatures.is_empty());
1478    }
1479
1480    #[test]
1481    fn schema_field_count() {
1482        assert_eq!(Ute::schema().len(), 43);
1483    }
1484
1485    #[test]
1486    fn schema_no_duplicate_labels() {
1487        let schema = Ute::schema();
1488        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1489        labels.sort();
1490        let before = labels.len();
1491        labels.dedup();
1492        assert_eq!(before, labels.len(), "duplicate labels in UTE schema");
1493    }
1494
1495    #[test]
1496    fn schema_lists_have_children() {
1497        let schema = Ute::schema();
1498        let geometry = schema
1499            .iter()
1500            .find(|f| f.label == "Geometry")
1501            .expect("test fixture must be valid");
1502        assert_eq!(
1503            geometry.children.expect("test fixture must be valid").len(),
1504            3
1505        );
1506        let creatures = schema
1507            .iter()
1508            .find(|f| f.label == "CreatureList")
1509            .expect("test fixture must be valid");
1510        assert_eq!(
1511            creatures
1512                .children
1513                .expect("test fixture must be valid")
1514                .len(),
1515            3
1516        );
1517        let spawn_points = schema
1518            .iter()
1519            .find(|f| f.label == "SpawnPointList")
1520            .expect("test fixture must be valid");
1521        assert_eq!(
1522            spawn_points
1523                .children
1524                .expect("test fixture must be valid")
1525                .len(),
1526            4
1527        );
1528        let area_list = schema
1529            .iter()
1530            .find(|f| f.label == "AreaList")
1531            .expect("test fixture must be valid");
1532        assert_eq!(
1533            area_list
1534                .children
1535                .expect("test fixture must be valid")
1536                .len(),
1537            1
1538        );
1539        let spawn_list = schema
1540            .iter()
1541            .find(|f| f.label == "SpawnList")
1542            .expect("test fixture must be valid");
1543        assert_eq!(
1544            spawn_list
1545                .children
1546                .expect("test fixture must be valid")
1547                .len(),
1548            2
1549        );
1550    }
1551}