Skip to main content

rakata_generics/
git.rs

1//! GIT (`.git`) typed generic wrapper.
2//!
3//! GIT (Game Instance Template) resources are GFF-backed area instance data.
4//! Each area has one GIT that holds positioned object instances (creatures,
5//! items, doors, placeables, waypoints, sounds, triggers, stores, encounters,
6//! area effects), area-level properties (music, ambient sounds, stealth XP),
7//! and a camera list.
8//!
9//! ## Scope
10//! - Typed access for all 10 instance list types with position/orientation.
11//! - Typed `AreaProperties` with music, ambient sound, and stealth XP fields.
12//! - Typed camera list with Position (Vector3) and Orientation (Quaternion).
13//! - Full typed roundtrip for all engine-read fields.
14
15use std::io::{Cursor, Read, Write};
16
17use crate::gff_helpers::{get_bool, get_u32, get_u8, upsert_field};
18use crate::git::creature::SavedCreature;
19use crate::git::door::SavedDoor;
20use crate::git::item::SavedItem;
21use crate::git::placeable::SavedPlaceable;
22use crate::git::sound::SavedSound;
23use crate::git::store::SavedStore;
24use crate::git::trigger::SavedTrigger;
25use crate::shared::ObjectId;
26use rakata_formats::{
27    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
28    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffStruct, GffValue,
29};
30use thiserror::Error;
31
32pub mod area_effect;
33pub mod area_properties;
34pub mod camera;
35pub mod creature;
36pub mod door;
37pub mod encounter;
38pub mod item;
39pub mod placeable;
40pub mod sound;
41pub mod store;
42pub mod trigger;
43pub mod waypoint;
44
45pub use area_effect::{GitAreaEffect, GitAreaEffectShape};
46pub use area_properties::GitAreaProperties;
47pub use camera::GitCamera;
48pub use creature::GitCreature;
49pub use door::GitDoor;
50pub use encounter::{GitEncounter, GitEncounterPoint, GitSpawnPoint};
51pub use item::GitItem;
52pub use placeable::GitPlaceable;
53pub use sound::GitSound;
54pub use store::GitStore;
55pub use trigger::GitTrigger;
56pub use waypoint::{GitMapNote, GitWaypoint};
57
58// =========================================================================
59// Instance sub-types
60// =========================================================================
61
62/// The two forms an object list in a `GIT` can take.
63///
64/// `UseTemplates` decides which, for every object list at once. A module's
65/// static `.git` sets it to `1`: each element is a sparse placement carrying
66/// a `TemplateResRef`, and the engine loads the matching blueprint and
67/// overlays the instance fields the element holds. A savegame `GIT` leaves it
68/// at `0`, and each element is a full self-contained snapshot read field by
69/// field, with no template to resolve. A field missing from a saved element
70/// falls back to the engine's hardcoded default rather than to a blueprint
71/// value.
72///
73/// The flag is not kept as a separate field on [`Git`], because it describes
74/// exactly this choice and storing both would let them disagree.
75///
76/// # Three lists are not one of these
77///
78/// Waypoints, because `LoadWaypoints` ignores `UseTemplates` and always
79/// reads inline data, so there is nothing to select between. Area effects,
80/// because no loader in the binary opens a template for one, so that list is
81/// only ever the saved form. Encounters, because their saved field set is
82/// not modelled yet; see [`Git::encounters`] for why.
83#[derive(Debug, Clone, PartialEq)]
84pub enum GitObjects<S, T> {
85    /// `UseTemplates = 1`: sparse placements referencing blueprints.
86    Static(Vec<S>),
87    /// `UseTemplates = 0`: full snapshots, as a savegame stores them.
88    Saved(Vec<T>),
89}
90
91impl<S, T> Default for GitObjects<S, T> {
92    /// Matches the engine's loader default of `0` for an absent
93    /// `UseTemplates`.
94    fn default() -> Self {
95        Self::Saved(Vec::new())
96    }
97}
98
99impl<S, T> GitObjects<S, T> {
100    /// Returns whether this list references blueprints rather than carrying
101    /// snapshots, which is what `UseTemplates` records.
102    pub fn uses_templates(&self) -> bool {
103        matches!(self, Self::Static(_))
104    }
105
106    /// Returns the number of entries, whichever form they take.
107    pub fn len(&self) -> usize {
108        match self {
109            Self::Static(entries) => entries.len(),
110            Self::Saved(entries) => entries.len(),
111        }
112    }
113
114    /// Returns whether the list holds no entries.
115    pub fn is_empty(&self) -> bool {
116        self.len() == 0
117    }
118
119    /// Returns the static placements, or `None` when the list is saved content.
120    pub fn as_static(&self) -> Option<&[S]> {
121        match self {
122            Self::Static(entries) => Some(entries),
123            Self::Saved(_) => None,
124        }
125    }
126
127    /// Returns the saved snapshots, or `None` when the list is static content.
128    pub fn as_saved(&self) -> Option<&[T]> {
129        match self {
130            Self::Saved(entries) => Some(entries),
131            Self::Static(_) => None,
132        }
133    }
134
135    /// Mutable view of the static placements, or `None` for saved content.
136    pub fn as_static_mut(&mut self) -> Option<&mut Vec<S>> {
137        match self {
138            Self::Static(entries) => Some(entries),
139            Self::Saved(_) => None,
140        }
141    }
142
143    /// Mutable view of the saved snapshots, or `None` for static content.
144    pub fn as_saved_mut(&mut self) -> Option<&mut Vec<T>> {
145        match self {
146            Self::Saved(entries) => Some(entries),
147            Self::Static(_) => None,
148        }
149    }
150}
151
152/// Reads a runtime `ObjectId`, falling back to [`ObjectId::INVALID`].
153///
154/// Every GIT object type shares this default, and reading it through one
155/// helper is what stops the next type added from picking zero again.
156///
157/// The `Default` impls derived on these types still produce zero for the
158/// field, so `T::default()` and `T` read from an empty struct disagree. The
159/// fix is a newtype whose own `Default` is the placeholder, which lets every
160/// type keep its derive.
161pub(crate) fn object_id_or_invalid(s: &GffStruct) -> ObjectId {
162    get_u32(s, "ObjectId").map_or(ObjectId::INVALID, ObjectId::new)
163}
164
165/// A `Creature List` in either of its two forms.
166pub type GitCreatures = GitObjects<GitCreature, SavedCreature>;
167
168/// A `Door List` in either of its two forms.
169pub type GitDoors = GitObjects<GitDoor, SavedDoor>;
170
171/// A `Placeable List` in either of its two forms.
172pub type GitPlaceables = GitObjects<GitPlaceable, SavedPlaceable>;
173
174/// A `List` of loose area items in either of its two forms.
175pub type GitItems = GitObjects<GitItem, SavedItem>;
176
177/// A `SoundList` in either of its two forms.
178pub type GitSounds = GitObjects<GitSound, SavedSound>;
179
180/// A `StoreList` in either of its two forms.
181pub type GitStores = GitObjects<GitStore, SavedStore>;
182
183/// A `TriggerList` in either of its two forms.
184pub type GitTriggers = GitObjects<GitTrigger, SavedTrigger>;
185
186// =========================================================================
187// Non-instance sub-types
188// =========================================================================
189
190// =========================================================================
191// Root GIT struct
192// =========================================================================
193
194/// Typed GIT model built from/to [`Gff`] data.
195///
196/// GIT is the area instance container. It places object instances by
197/// referencing templates and providing position/orientation data. All
198/// engine-read fields are typed; roundtrip is fully lossless for typed
199/// fields.
200#[derive(Debug, Clone, PartialEq, Default)]
201pub struct Git {
202    // --- Root scalars ---
203    /// Current weather (`CurrentWeather`). If area is an interior, engine forcibly overrides to 0xFF.
204    pub current_weather: u8,
205    /// Weather started flag (`WeatherStarted`). If area is an interior, engine forcibly overrides to 0 (false).
206    pub weather_started: bool,
207    // --- Object instance lists (10) ---
208    /// Creature instances (`Creature List`), in whichever form the file uses.
209    ///
210    /// The `UseTemplates` discriminator lives here rather than as a separate
211    /// flag, so the two cannot disagree about what the list contains.
212    pub creatures: GitCreatures,
213    /// Item instances (`List`), in whichever form the file uses.
214    pub items: GitItems,
215    /// Door instances (`Door List`), in whichever form the file uses.
216    pub doors: GitDoors,
217    /// Placeable instances (`Placeable List`), in whichever form the file uses.
218    pub placeables: GitPlaceables,
219    /// Waypoint instances (`WaypointList`).
220    pub waypoints: Vec<GitWaypoint>,
221    /// Sound instances (`SoundList`), in whichever form the file uses.
222    pub sounds: GitSounds,
223    /// Trigger instances (`TriggerList`), in whichever form the file uses.
224    pub triggers: GitTriggers,
225    /// Store instances (`StoreList`), in whichever form the file uses.
226    pub stores: GitStores,
227    /// Encounter instances (`Encounter List`).
228    ///
229    /// Read as static placements whatever `UseTemplates` says, which is
230    /// wrong for a savegame `GIT`: a saved encounter comes back with an
231    /// empty `TemplateResRef` rather than its spawned state. Encounters are
232    /// the one object list still in that position. They are held back
233    /// because there is nothing to model them against: every save in
234    /// `fixtures/saves/` has an empty `Encounter List`, and unlike area
235    /// effects the engine's saved encounter field set is not written down in
236    /// `docs/src/formats/gff/git.md` either. Modelling it from `LoadEncounters`
237    /// alone would ship a field set with no way to check it.
238    pub encounters: Vec<GitEncounter>,
239    /// Area-of-effect instances (`AreaEffectList`).
240    pub area_effects: Vec<GitAreaEffect>,
241
242    // --- Nested structs ---
243    /// Area-level properties (`AreaProperties`).
244    pub area_properties: Option<GitAreaProperties>,
245
246    // --- Camera list ---
247    /// Static cameras (`CameraList`). Engine load failure occurs if array contains 51 or more entries.
248    pub cameras: Vec<GitCamera>,
249}
250
251impl Git {
252    /// Creates an empty GIT value.
253    pub fn new() -> Self {
254        Self::default()
255    }
256
257    /// Builds typed GIT data from a parsed GFF container.
258    pub fn from_gff(gff: &Gff) -> Result<Self, GitError> {
259        if gff.file_type != *b"GIT " && gff.file_type != *b"GFF " {
260            return Err(GitError::UnsupportedFileType(gff.file_type));
261        }
262
263        let root = &gff.root;
264
265        fn read_list<T>(root: &GffStruct, label: &str, f: fn(&GffStruct) -> T) -> Vec<T> {
266            match root.field(label) {
267                Some(GffValue::List(elements)) => elements.iter().map(f).collect(),
268                _ => Vec::new(),
269            }
270        }
271
272        /// Reads one object list in whichever form `UseTemplates` selects.
273        fn read_objects<S, T>(
274            root: &GffStruct,
275            label: &str,
276            use_templates: bool,
277            read_static: fn(&GffStruct) -> S,
278            read_saved: fn(&GffStruct) -> T,
279        ) -> GitObjects<S, T> {
280            if use_templates {
281                GitObjects::Static(read_list(root, label, read_static))
282            } else {
283                GitObjects::Saved(read_list(root, label, read_saved))
284            }
285        }
286
287        let use_templates = get_bool(root, "UseTemplates").unwrap_or(false);
288
289        let area_properties = match root.field("AreaProperties") {
290            Some(GffValue::Struct(s)) => Some(GitAreaProperties::from_gff_struct(s)),
291            _ => None,
292        };
293
294        Ok(Self {
295            current_weather: get_u8(root, "CurrentWeather").unwrap_or(0),
296            weather_started: get_bool(root, "WeatherStarted").unwrap_or(false),
297
298            creatures: if get_bool(root, "UseTemplates").unwrap_or(false) {
299                GitCreatures::Static(read_list(
300                    root,
301                    "Creature List",
302                    GitCreature::from_gff_struct,
303                ))
304            } else {
305                GitCreatures::Saved(read_list(root, "Creature List", SavedCreature::from_struct))
306            },
307            items: read_objects(
308                root,
309                "List",
310                use_templates,
311                GitItem::from_gff_struct,
312                SavedItem::from_struct,
313            ),
314            doors: read_objects(
315                root,
316                "Door List",
317                use_templates,
318                GitDoor::from_gff_struct,
319                SavedDoor::from_struct,
320            ),
321            placeables: read_objects(
322                root,
323                "Placeable List",
324                use_templates,
325                GitPlaceable::from_gff_struct,
326                SavedPlaceable::from_struct,
327            ),
328            waypoints: read_list(root, "WaypointList", GitWaypoint::from_gff_struct),
329            sounds: read_objects(
330                root,
331                "SoundList",
332                use_templates,
333                GitSound::from_gff_struct,
334                SavedSound::from_struct,
335            ),
336            triggers: read_objects(
337                root,
338                "TriggerList",
339                use_templates,
340                GitTrigger::from_gff_struct,
341                SavedTrigger::from_struct,
342            ),
343            stores: read_objects(
344                root,
345                "StoreList",
346                use_templates,
347                GitStore::from_gff_struct,
348                SavedStore::from_struct,
349            ),
350            encounters: read_list(root, "Encounter List", GitEncounter::from_gff_struct),
351            area_effects: read_list(root, "AreaEffectList", GitAreaEffect::from_gff_struct),
352
353            area_properties,
354            cameras: read_list(root, "CameraList", GitCamera::from_gff_struct),
355        })
356    }
357
358    /// Converts this typed GIT value into a GFF container.
359    pub fn to_gff(&self) -> Gff {
360        let mut root = GffStruct::new(-1);
361
362        upsert_field(
363            &mut root,
364            "CurrentWeather",
365            GffValue::UInt8(self.current_weather),
366        );
367        upsert_field(
368            &mut root,
369            "WeatherStarted",
370            GffValue::UInt8(u8::from(self.weather_started)),
371        );
372        // A static module `.git` sets the flag; a savegame `GIT` omits it,
373        // because `SaveGIT` never writes it and the loader defaults to 0.
374        if self.creatures.uses_templates() {
375            upsert_field(&mut root, "UseTemplates", GffValue::UInt8(1));
376        }
377
378        fn write_list<T>(root: &mut GffStruct, label: &str, items: &[T], f: fn(&T) -> GffStruct) {
379            let structs: Vec<GffStruct> = items.iter().map(f).collect();
380            upsert_field(root, label, GffValue::List(structs));
381        }
382
383        /// Writes one object list back in whichever form it holds.
384        fn write_objects<S, T>(
385            root: &mut GffStruct,
386            label: &str,
387            objects: &GitObjects<S, T>,
388            write_static: fn(&S) -> GffStruct,
389            write_saved: fn(&T, usize) -> GffStruct,
390        ) {
391            let structs: Vec<GffStruct> = match objects {
392                GitObjects::Static(entries) => entries.iter().map(write_static).collect(),
393                GitObjects::Saved(entries) => entries
394                    .iter()
395                    .enumerate()
396                    .map(|(index, entry)| write_saved(entry, index))
397                    .collect(),
398            };
399            upsert_field(root, label, GffValue::List(structs));
400        }
401
402        write_objects(
403            &mut root,
404            "Creature List",
405            &self.creatures,
406            GitCreature::to_gff_struct,
407            SavedCreature::to_struct,
408        );
409        write_objects(
410            &mut root,
411            "List",
412            &self.items,
413            GitItem::to_gff_struct,
414            SavedItem::to_struct,
415        );
416        write_objects(
417            &mut root,
418            "Door List",
419            &self.doors,
420            GitDoor::to_gff_struct,
421            SavedDoor::to_struct,
422        );
423        write_objects(
424            &mut root,
425            "Placeable List",
426            &self.placeables,
427            GitPlaceable::to_gff_struct,
428            SavedPlaceable::to_struct,
429        );
430        write_list(
431            &mut root,
432            "WaypointList",
433            &self.waypoints,
434            GitWaypoint::to_gff_struct,
435        );
436        write_objects(
437            &mut root,
438            "SoundList",
439            &self.sounds,
440            GitSound::to_gff_struct,
441            SavedSound::to_struct,
442        );
443        write_objects(
444            &mut root,
445            "TriggerList",
446            &self.triggers,
447            GitTrigger::to_gff_struct,
448            SavedTrigger::to_struct,
449        );
450        write_objects(
451            &mut root,
452            "StoreList",
453            &self.stores,
454            GitStore::to_gff_struct,
455            SavedStore::to_struct,
456        );
457        write_list(
458            &mut root,
459            "Encounter List",
460            &self.encounters,
461            GitEncounter::to_gff_struct,
462        );
463        write_list(
464            &mut root,
465            "AreaEffectList",
466            &self.area_effects,
467            GitAreaEffect::to_gff_struct,
468        );
469
470        if let Some(ref ap) = self.area_properties {
471            upsert_field(
472                &mut root,
473                "AreaProperties",
474                GffValue::Struct(Box::new(ap.to_gff_struct())),
475            );
476        }
477
478        write_list(
479            &mut root,
480            "CameraList",
481            &self.cameras,
482            GitCamera::to_gff_struct,
483        );
484
485        Gff::new(*b"GIT ", root)
486    }
487}
488
489/// Errors produced while reading or writing typed GIT data.
490#[derive(Debug, Error)]
491pub enum GitError {
492    /// Source file type is not supported by this parser.
493    #[error("unsupported GIT file type: {0:?}")]
494    UnsupportedFileType([u8; 4]),
495    /// Underlying GFF parser/writer error.
496    #[error(transparent)]
497    Gff(#[from] GffBinaryError),
498}
499
500/// Reads typed GIT data from a reader at the current stream position.
501#[cfg_attr(
502    feature = "tracing",
503    tracing::instrument(level = "debug", skip(reader))
504)]
505pub fn read_git<R: Read>(reader: &mut R) -> Result<Git, GitError> {
506    let gff = read_gff(reader)?;
507    Git::from_gff(&gff)
508}
509
510/// Reads typed GIT data directly from bytes.
511#[cfg_attr(
512    feature = "tracing",
513    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
514)]
515pub fn read_git_from_bytes(bytes: &[u8]) -> Result<Git, GitError> {
516    let gff = read_gff_from_bytes(bytes)?;
517    Git::from_gff(&gff)
518}
519
520/// Writes typed GIT data to an output writer.
521#[cfg_attr(
522    feature = "tracing",
523    tracing::instrument(level = "debug", skip(writer, git))
524)]
525pub fn write_git<W: Write>(writer: &mut W, git: &Git) -> Result<(), GitError> {
526    let gff = git.to_gff();
527    write_gff(writer, &gff)?;
528    Ok(())
529}
530
531/// Serializes typed GIT data into a byte vector.
532#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(git)))]
533pub fn write_git_to_vec(git: &Git) -> Result<Vec<u8>, GitError> {
534    let mut cursor = Cursor::new(Vec::new());
535    write_git(&mut cursor, git)?;
536    Ok(cursor.into_inner())
537}
538
539// =========================================================================
540// Leaf sub-schemas (no nested children)
541// =========================================================================
542
543impl GffSchema for Git {
544    fn schema() -> &'static [FieldSchema] {
545        static SCHEMA: &[FieldSchema] = &[
546            // --- Root scalars ---
547            FieldSchema {
548                label: "CurrentWeather",
549                expected_type: GffType::UInt8,
550                life: FieldLife::Live,
551                required: false,
552                absent: AbsentDefault::Unverified,
553                children: None,
554                constraint: None,
555            },
556            FieldSchema {
557                label: "WeatherStarted",
558                expected_type: GffType::UInt8,
559                life: FieldLife::Live,
560                required: false,
561                absent: AbsentDefault::Unverified,
562                children: None,
563                constraint: None,
564            },
565            FieldSchema {
566                label: "UseTemplates",
567                expected_type: GffType::UInt8,
568                life: FieldLife::Live,
569                required: false,
570                absent: AbsentDefault::Unverified,
571                children: None,
572                constraint: None,
573            },
574            // --- Object instance lists (10) ---
575            FieldSchema {
576                label: "Creature List",
577                expected_type: GffType::List,
578                life: FieldLife::Live,
579                required: false,
580                absent: AbsentDefault::Unverified,
581                children: Some(creature::CREATURE_LIST_CHILDREN),
582                constraint: None,
583            },
584            FieldSchema {
585                label: "List",
586                expected_type: GffType::List,
587                life: FieldLife::Live,
588                required: false,
589                absent: AbsentDefault::Unverified,
590                children: Some(item::ITEM_LIST_CHILDREN),
591                constraint: None,
592            },
593            FieldSchema {
594                label: "Door List",
595                expected_type: GffType::List,
596                life: FieldLife::Live,
597                required: false,
598                absent: AbsentDefault::Unverified,
599                children: Some(door::DOOR_LIST_CHILDREN),
600                constraint: None,
601            },
602            FieldSchema {
603                label: "Placeable List",
604                expected_type: GffType::List,
605                life: FieldLife::Live,
606                required: false,
607                absent: AbsentDefault::Unverified,
608                children: Some(placeable::PLACEABLE_LIST_CHILDREN),
609                constraint: None,
610            },
611            FieldSchema {
612                label: "WaypointList",
613                expected_type: GffType::List,
614                life: FieldLife::Live,
615                required: false,
616                absent: AbsentDefault::Unverified,
617                children: Some(waypoint::WAYPOINT_LIST_CHILDREN),
618                constraint: None,
619            },
620            FieldSchema {
621                label: "SoundList",
622                expected_type: GffType::List,
623                life: FieldLife::Live,
624                required: false,
625                absent: AbsentDefault::Unverified,
626                children: Some(sound::SOUND_LIST_CHILDREN),
627                constraint: None,
628            },
629            FieldSchema {
630                label: "TriggerList",
631                expected_type: GffType::List,
632                life: FieldLife::Live,
633                required: false,
634                absent: AbsentDefault::Unverified,
635                children: Some(trigger::TRIGGER_LIST_CHILDREN),
636                constraint: None,
637            },
638            FieldSchema {
639                label: "StoreList",
640                expected_type: GffType::List,
641                life: FieldLife::Live,
642                required: false,
643                absent: AbsentDefault::Unverified,
644                children: Some(store::STORE_LIST_CHILDREN),
645                constraint: None,
646            },
647            FieldSchema {
648                label: "Encounter List",
649                expected_type: GffType::List,
650                life: FieldLife::Live,
651                required: false,
652                absent: AbsentDefault::Unverified,
653                children: Some(encounter::ENCOUNTER_LIST_CHILDREN),
654                constraint: None,
655            },
656            FieldSchema {
657                label: "AreaEffectList",
658                expected_type: GffType::List,
659                life: FieldLife::Live,
660                required: false,
661                absent: AbsentDefault::Unverified,
662                children: Some(area_effect::AREA_EFFECT_LIST_CHILDREN),
663                constraint: None,
664            },
665            // --- Nested structs (2) ---
666            FieldSchema {
667                label: "AreaProperties",
668                expected_type: GffType::Struct,
669                life: FieldLife::Live,
670                required: false,
671                absent: AbsentDefault::Unverified,
672                children: Some(area_properties::AREA_PROPERTIES_CHILDREN),
673                constraint: None,
674            },
675            FieldSchema {
676                label: "AreaMap",
677                expected_type: GffType::Struct,
678                life: FieldLife::Live,
679                required: false,
680                absent: AbsentDefault::Unverified,
681                children: Some(area_properties::AREA_MAP_CHILDREN),
682                constraint: None,
683            },
684            // --- Camera list ---
685            FieldSchema {
686                label: "CameraList",
687                expected_type: GffType::List,
688                life: FieldLife::Live,
689                required: false,
690                absent: AbsentDefault::Unverified,
691                children: Some(camera::CAMERA_LIST_CHILDREN),
692                constraint: None,
693            },
694            // --- Variable table ---
695            FieldSchema {
696                label: "VarTable",
697                expected_type: GffType::List,
698                life: FieldLife::Live,
699                required: false,
700                absent: AbsentDefault::Unverified,
701                children: None,
702                constraint: None,
703            },
704        ];
705        SCHEMA
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    use rakata_core::{ResRef, StrRef};
714    use rakata_formats::GffLocalizedString;
715
716    /// Build a minimal GIT GFF for testing.
717    fn make_test_git_gff() -> Gff {
718        let mut root = GffStruct::new(-1);
719        root.push_field("CurrentWeather", GffValue::UInt8(0));
720        root.push_field("WeatherStarted", GffValue::UInt8(0));
721        root.push_field("UseTemplates", GffValue::UInt8(1));
722
723        // One creature.
724        let mut creature = GffStruct::new(4);
725        creature.push_field("TemplateResRef", GffValue::resref_lit("n_darthbandon"));
726        creature.push_field("XPosition", GffValue::Single(10.0));
727        creature.push_field("YPosition", GffValue::Single(20.0));
728        creature.push_field("ZPosition", GffValue::Single(0.5));
729        creature.push_field("XOrientation", GffValue::Single(0.0));
730        creature.push_field("YOrientation", GffValue::Single(1.0));
731        creature.push_field("ZOrientation", GffValue::Single(0.0));
732        creature.push_field("ObjectId", GffValue::UInt32(0));
733        root.push_field("Creature List", GffValue::List(vec![creature]));
734
735        // One placeable.
736        let mut placeable = GffStruct::new(9);
737        placeable.push_field("TemplateResRef", GffValue::resref_lit("plc_footlkr"));
738        placeable.push_field("Bearing", GffValue::Single(1.57));
739        placeable.push_field("X", GffValue::Single(15.0));
740        placeable.push_field("Y", GffValue::Single(25.0));
741        placeable.push_field("Z", GffValue::Single(0.0));
742        placeable.push_field("ObjectId", GffValue::UInt32(0));
743        root.push_field("Placeable List", GffValue::List(vec![placeable]));
744
745        // One trigger with geometry.
746        let mut trigger = GffStruct::new(1);
747        trigger.push_field("TemplateResRef", GffValue::resref_lit("newtransition9"));
748        trigger.push_field("LinkedToModule", GffValue::resref_lit("tar_m02aa"));
749        trigger.push_field(
750            "TransitionDestin",
751            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(12345))),
752        );
753        trigger.push_field("LinkedTo", GffValue::String("wp_target".into()));
754        trigger.push_field("LinkedToFlags", GffValue::UInt8(2));
755        trigger.push_field("XPosition", GffValue::Single(5.0));
756        trigger.push_field("YPosition", GffValue::Single(5.0));
757        trigger.push_field("ZPosition", GffValue::Single(0.0));
758        let mut pt0 = GffStruct::new(0);
759        pt0.push_field("PointX", GffValue::Single(0.0));
760        pt0.push_field("PointY", GffValue::Single(0.0));
761        pt0.push_field("PointZ", GffValue::Single(0.0));
762        let mut pt1 = GffStruct::new(0);
763        pt1.push_field("PointX", GffValue::Single(5.0));
764        pt1.push_field("PointY", GffValue::Single(0.0));
765        pt1.push_field("PointZ", GffValue::Single(0.0));
766        let mut pt2 = GffStruct::new(0);
767        pt2.push_field("PointX", GffValue::Single(5.0));
768        pt2.push_field("PointY", GffValue::Single(5.0));
769        pt2.push_field("PointZ", GffValue::Single(0.0));
770        trigger.push_field("Geometry", GffValue::List(vec![pt0, pt1, pt2]));
771        trigger.push_field("ObjectId", GffValue::UInt32(0));
772        root.push_field("TriggerList", GffValue::List(vec![trigger]));
773
774        // AreaProperties.
775        let mut props = GffStruct::new(0);
776        props.push_field("Unescapable", GffValue::UInt8(0));
777        props.push_field("StealthXPMax", GffValue::UInt32(100));
778        props.push_field("StealthXPCurrent", GffValue::UInt32(0));
779        props.push_field("StealthXPLoss", GffValue::UInt32(10));
780        props.push_field("StealthXPEnabled", GffValue::UInt8(1));
781        props.push_field("TransPending", GffValue::UInt8(0));
782        props.push_field("TransPendNextID", GffValue::UInt8(0));
783        props.push_field("TransPendCurrID", GffValue::UInt8(0));
784        props.push_field("SunFogColor", GffValue::UInt32(0x00ABCDEF));
785        props.push_field("MusicDelay", GffValue::Int32(6000));
786        props.push_field("MusicDay", GffValue::Int32(48));
787        props.push_field("MusicNight", GffValue::Int32(49));
788        props.push_field("MusicBattle", GffValue::Int32(25));
789        props.push_field("AmbientSndDay", GffValue::Int32(12));
790        props.push_field("AmbientSndNight", GffValue::Int32(13));
791        props.push_field("AmbientSndDayVol", GffValue::Int32(50));
792        props.push_field("AmbientSndNitVol", GffValue::Int32(40));
793        root.push_field("AreaProperties", GffValue::Struct(Box::new(props)));
794
795        // Empty lists for remaining types.
796        root.push_field("List", GffValue::List(vec![]));
797        root.push_field("Door List", GffValue::List(vec![]));
798        root.push_field("WaypointList", GffValue::List(vec![]));
799        root.push_field("SoundList", GffValue::List(vec![]));
800        root.push_field("StoreList", GffValue::List(vec![]));
801        root.push_field("Encounter List", GffValue::List(vec![]));
802        root.push_field("AreaEffectList", GffValue::List(vec![]));
803        root.push_field("CameraList", GffValue::List(vec![]));
804
805        Gff::new(*b"GIT ", root)
806    }
807
808    #[test]
809    fn reads_root_scalars() {
810        let gff = make_test_git_gff();
811        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
812
813        assert_eq!(git.current_weather, 0);
814        assert!(!git.weather_started);
815        assert!(git.creatures.uses_templates());
816    }
817
818    #[test]
819    fn reads_creature_list() {
820        let gff = make_test_git_gff();
821        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
822
823        assert_eq!(git.creatures.len(), 1);
824        let statics = git
825            .creatures
826            .as_static()
827            .expect("the fixture sets UseTemplates = 1");
828        let c = &statics[0];
829        assert_eq!(c.template_resref, "n_darthbandon");
830        assert!((c.x_position - 10.0).abs() < f32::EPSILON);
831        assert!((c.y_position - 20.0).abs() < f32::EPSILON);
832        assert!((c.y_orientation - 1.0).abs() < f32::EPSILON);
833    }
834
835    fn sample_area_effect() -> GitAreaEffect {
836        GitAreaEffect {
837            tag: "aoe_stasis".to_string(),
838            object_id: ObjectId::INVALID,
839            area_effect_id: 12,
840            spell_id: 44,
841            spell_save_dc: 18,
842            spell_level: 3,
843            shape: GitAreaEffectShape::Circle { radius: 5.0 },
844            duration: 30_000,
845            duration_type: 2,
846            position_x: 1.5,
847            orientation_y: 1.0,
848            ..GitAreaEffect::default()
849        }
850    }
851
852    #[test]
853    fn area_effect_round_trips_through_a_list_element() {
854        let effect = sample_area_effect();
855
856        let parsed = GitAreaEffect::from_gff_struct(&effect.to_gff_struct());
857
858        assert_eq!(parsed, effect);
859    }
860
861    #[test]
862    fn an_area_effect_writes_only_the_dimensions_its_shape_uses() {
863        // The loader reads Radius only for shape 0 and Length/Width only for
864        // shape 1, so writing the other pair puts fields on the object the
865        // engine would never read back.
866        let circle = sample_area_effect().to_gff_struct();
867        assert!(circle.field("Radius").is_some());
868        assert!(circle.field("Length").is_none());
869        assert!(circle.field("Width").is_none());
870
871        let rectangle = GitAreaEffect {
872            shape: GitAreaEffectShape::Rectangle {
873                length: 4.0,
874                width: 2.0,
875            },
876            ..sample_area_effect()
877        }
878        .to_gff_struct();
879        assert!(rectangle.field("Radius").is_none());
880        assert!(rectangle.field("Length").is_some());
881        assert!(rectangle.field("Width").is_some());
882
883        let shapeless = GitAreaEffect {
884            shape: GitAreaEffectShape::None(7),
885            ..sample_area_effect()
886        }
887        .to_gff_struct();
888        assert!(shapeless.field("Radius").is_none());
889        assert!(shapeless.field("Length").is_none());
890        assert_eq!(shapeless.field("Shape"), Some(&GffValue::UInt8(7)));
891    }
892
893    #[test]
894    fn a_door_placement_keeps_its_blueprint_and_transition() {
895        // The view modelled position and bearing only, so a vanilla door
896        // round-tripped into a placement with nothing to instantiate from
897        // and no transition. Both are set in vanilla modules.
898        let mut door = GffStruct::new(8);
899        door.push_field(
900            "TemplateResRef",
901            GffValue::ResRef(ResRef::new("m13aa_door01").expect("valid test resref")),
902        );
903        door.push_field("Tag", GffValue::String("EnclaveDoor".into()));
904        door.push_field("Bearing", GffValue::Single(1.57));
905        door.push_field(
906            "LinkedToModule",
907            GffValue::ResRef(ResRef::new("danm14aa").expect("valid test resref")),
908        );
909        door.push_field("LinkedTo", GffValue::String("from_courtyard".into()));
910        door.push_field("LinkedToFlags", GffValue::UInt8(2));
911        door.push_field(
912            "TransitionDestin",
913            GffValue::LocalizedString(GffLocalizedString::new(
914                StrRef::from_index(31982).expect("valid test strref"),
915            )),
916        );
917
918        let parsed = GitDoor::from_gff_struct(&door);
919        assert_eq!(parsed.template_resref, "m13aa_door01");
920        assert_eq!(parsed.linked_to_module, "danm14aa");
921        assert_eq!(parsed.linked_to, "from_courtyard");
922        assert_eq!(parsed.linked_to_flags, 2);
923        assert_eq!(
924            parsed.transition_destination.string_ref,
925            StrRef::from_index(31982).expect("valid test strref")
926        );
927
928        let written = parsed.to_gff_struct();
929        for label in [
930            "TemplateResRef",
931            "LinkedToModule",
932            "LinkedTo",
933            "LinkedToFlags",
934            "TransitionDestin",
935        ] {
936            assert_eq!(
937                written.field(label),
938                door.field(label),
939                "{label} must survive the round-trip unchanged"
940            );
941        }
942
943        // `Tag` is the one label here the engine does not read at this path:
944        // a templated door takes its tag from the blueprint with no overlay.
945        // Modelling it would hand callers a field that looks settable and
946        // does nothing, so the view drops it and a raw-tree rule reports it.
947        assert!(
948            written.field("Tag").is_none(),
949            "the placement's Tag is dead and must not be written back"
950        );
951    }
952
953    #[test]
954    fn an_absent_object_id_is_the_invalid_placeholder_on_every_type() {
955        // Zero is a valid object id, so defaulting there claims an unplaced
956        // object refers to whichever object the engine numbered first. Only
957        // the area effect got this right; every other type read zero. No
958        // vanilla static GIT carries ObjectId at all, so every one of them
959        // was affected.
960        let empty = GffStruct::new(0);
961
962        // No `GitCreature` here: the static creature path never reads the
963        // label, so the type does not model it. See `git/creature.rs`.
964        assert_eq!(
965            GitItem::from_gff_struct(&empty).object_id,
966            ObjectId::INVALID
967        );
968        assert_eq!(
969            GitDoor::from_gff_struct(&empty).object_id,
970            ObjectId::INVALID
971        );
972        assert_eq!(
973            GitPlaceable::from_gff_struct(&empty).object_id,
974            ObjectId::INVALID
975        );
976        assert_eq!(
977            GitWaypoint::from_gff_struct(&empty).object_id,
978            ObjectId::INVALID
979        );
980        assert_eq!(
981            GitSound::from_gff_struct(&empty).object_id,
982            ObjectId::INVALID
983        );
984        assert_eq!(
985            GitTrigger::from_gff_struct(&empty).object_id,
986            ObjectId::INVALID
987        );
988        assert_eq!(
989            GitStore::from_gff_struct(&empty).object_id,
990            ObjectId::INVALID
991        );
992        assert_eq!(
993            GitEncounter::from_gff_struct(&empty).object_id,
994            ObjectId::INVALID
995        );
996        assert_eq!(
997            GitAreaEffect::from_gff_struct(&GffStruct::new(13)).object_id,
998            ObjectId::INVALID
999        );
1000
1001        // The saved forms read the same label through the same instruction,
1002        // which runs before the static-versus-save branch, so they cannot
1003        // legitimately differ from the placements above. They defaulted to
1004        // zero until this was checked rather than assumed.
1005        assert_eq!(
1006            crate::git::creature::SavedCreature::from_struct(&empty).object_id,
1007            ObjectId::INVALID
1008        );
1009        assert_eq!(
1010            crate::git::door::SavedDoor::from_struct(&empty).object_id,
1011            ObjectId::INVALID
1012        );
1013        assert_eq!(
1014            crate::git::item::SavedItem::from_struct(&empty).object_id,
1015            ObjectId::INVALID
1016        );
1017        assert_eq!(
1018            crate::git::placeable::SavedPlaceable::from_struct(&empty).object_id,
1019            ObjectId::INVALID
1020        );
1021        assert_eq!(
1022            crate::git::sound::SavedSound::from_struct(&empty).object_id,
1023            ObjectId::INVALID
1024        );
1025        assert_eq!(
1026            crate::git::store::SavedStore::from_struct(&empty).object_id,
1027            ObjectId::INVALID
1028        );
1029        assert_eq!(
1030            crate::git::trigger::SavedTrigger::from_struct(&empty).object_id,
1031            ObjectId::INVALID
1032        );
1033    }
1034
1035    #[test]
1036    fn reads_placeable_list() {
1037        let gff = make_test_git_gff();
1038        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1039
1040        assert_eq!(git.placeables.len(), 1);
1041        let statics = git
1042            .placeables
1043            .as_static()
1044            .expect("the fixture sets UseTemplates = 1");
1045        let p = &statics[0];
1046        assert_eq!(p.template_resref, "plc_footlkr");
1047        assert!((p.bearing - 1.57).abs() < 0.01);
1048        assert!((p.x - 15.0).abs() < f32::EPSILON);
1049    }
1050
1051    #[test]
1052    fn reads_trigger_with_geometry() {
1053        let gff = make_test_git_gff();
1054        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1055
1056        assert_eq!(git.triggers.len(), 1);
1057        let statics = git
1058            .triggers
1059            .as_static()
1060            .expect("the fixture sets UseTemplates = 1");
1061        let t = &statics[0];
1062        assert_eq!(t.template_resref, "newtransition9");
1063        assert_eq!(t.linked_to_module, "tar_m02aa");
1064        assert_eq!(t.linked_to, "wp_target");
1065        assert_eq!(t.linked_to_flags, 2);
1066        assert_eq!(t.geometry.len(), 3);
1067        assert!((t.geometry[1].point_x - 5.0).abs() < f32::EPSILON);
1068    }
1069
1070    #[test]
1071    fn reads_area_properties() {
1072        let gff = make_test_git_gff();
1073        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1074
1075        let ap = git
1076            .area_properties
1077            .as_ref()
1078            .expect("test GFF includes AreaProperties");
1079        assert!(!ap.unescapable);
1080        assert_eq!(ap.stealth_xp_max, 100);
1081        assert!(ap.stealth_xp_enabled);
1082        assert_eq!(ap.music_day, 48);
1083        assert_eq!(ap.music_night, 49);
1084        assert_eq!(ap.music_battle, 25);
1085        assert_eq!(ap.ambient_snd_day_vol, 50);
1086        assert_eq!(ap.ambient_snd_nit_vol, 40);
1087    }
1088
1089    #[test]
1090    fn all_fields_survive_synthetic_roundtrip() {
1091        let gff = make_test_git_gff();
1092        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1093        let bytes = write_git_to_vec(&git).expect("write succeeds");
1094        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1095
1096        assert_eq!(reparsed, git);
1097    }
1098
1099    #[test]
1100    fn typed_edits_roundtrip_through_gff_writer() {
1101        let gff = make_test_git_gff();
1102        let mut git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1103        let statics = git
1104            .creatures
1105            .as_static_mut()
1106            .expect("the fixture sets UseTemplates = 1");
1107        statics[0].template_resref = ResRef::new("n_sithsoldier").expect("valid test resref");
1108        statics[0].x_position = 100.0;
1109        git.area_properties
1110            .as_mut()
1111            .expect("test GFF includes AreaProperties")
1112            .music_day = 99;
1113
1114        let bytes = write_git_to_vec(&git).expect("write succeeds");
1115        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1116
1117        let reparsed_statics = reparsed
1118            .creatures
1119            .as_static()
1120            .expect("round-trip keeps UseTemplates = 1");
1121        assert_eq!(reparsed_statics[0].template_resref, "n_sithsoldier");
1122        assert!((reparsed_statics[0].x_position - 100.0).abs() < f32::EPSILON);
1123        assert_eq!(
1124            reparsed
1125                .area_properties
1126                .as_ref()
1127                .expect("roundtripped GIT must have AreaProperties")
1128                .music_day,
1129            99
1130        );
1131    }
1132
1133    #[test]
1134    fn read_git_from_reader_matches_bytes_path() {
1135        let gff = make_test_git_gff();
1136        let bytes = {
1137            let mut c = Cursor::new(Vec::new());
1138            write_gff(&mut c, &gff).expect("GFF write to cursor always succeeds");
1139            c.into_inner()
1140        };
1141
1142        let mut cursor = Cursor::new(&bytes);
1143        let via_reader = read_git(&mut cursor).expect("reader parse succeeds");
1144        let via_bytes = read_git_from_bytes(&bytes).expect("bytes parse succeeds");
1145
1146        assert_eq!(via_reader, via_bytes);
1147    }
1148
1149    #[test]
1150    fn rejects_non_git_file_type() {
1151        let mut gff = make_test_git_gff();
1152        gff.file_type = *b"UTW ";
1153
1154        let err = Git::from_gff(&gff).expect_err("UTW must be rejected as GIT input");
1155        assert!(matches!(
1156            err,
1157            GitError::UnsupportedFileType(ft) if ft == *b"UTW "
1158        ));
1159    }
1160
1161    #[test]
1162    fn write_git_matches_direct_gff_writer() {
1163        let gff = make_test_git_gff();
1164        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1165
1166        let via_typed = write_git_to_vec(&git).expect("typed write succeeds");
1167
1168        let mut direct = Cursor::new(Vec::new());
1169        write_gff(&mut direct, &git.to_gff()).expect("direct write succeeds");
1170
1171        assert_eq!(via_typed, direct.into_inner());
1172    }
1173
1174    // --- Fixture tests ---
1175
1176    const TEST_GIT: &[u8] = include_bytes!(concat!(
1177        env!("CARGO_MANIFEST_DIR"),
1178        "/../../fixtures/test.git"
1179    ));
1180    const K1_GIT: &[u8] = include_bytes!(concat!(
1181        env!("CARGO_MANIFEST_DIR"),
1182        "/../../fixtures/k1_same_git_test.git"
1183    ));
1184
1185    #[test]
1186    fn parses_test_git_fixture() {
1187        let git = read_git_from_bytes(TEST_GIT).expect("test.git must parse");
1188        // Should have at least some instance data.
1189        let total = git.creatures.len()
1190            + git.items.len()
1191            + git.doors.len()
1192            + git.placeables.len()
1193            + git.waypoints.len()
1194            + git.sounds.len()
1195            + git.triggers.len()
1196            + git.stores.len()
1197            + git.encounters.len()
1198            + git.area_effects.len();
1199        assert!(total > 0, "fixture should have some instance data");
1200    }
1201
1202    #[test]
1203    fn parses_k1_git_fixture() {
1204        let git = read_git_from_bytes(K1_GIT).expect("k1 GIT must parse");
1205        let total = git.creatures.len()
1206            + git.items.len()
1207            + git.doors.len()
1208            + git.placeables.len()
1209            + git.waypoints.len()
1210            + git.sounds.len()
1211            + git.triggers.len()
1212            + git.stores.len()
1213            + git.encounters.len()
1214            + git.area_effects.len();
1215        assert!(total > 0, "K1 fixture should have some instance data");
1216    }
1217
1218    #[test]
1219    fn all_fields_survive_typed_roundtrip() {
1220        let git = read_git_from_bytes(TEST_GIT).expect("test.git must parse");
1221        let bytes = write_git_to_vec(&git).expect("write succeeds");
1222        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1223
1224        assert_eq!(reparsed, git);
1225    }
1226
1227    #[test]
1228    fn all_fields_survive_typed_roundtrip_k1() {
1229        let git = read_git_from_bytes(K1_GIT).expect("k1 GIT must parse");
1230        let bytes = write_git_to_vec(&git).expect("write succeeds");
1231        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1232
1233        assert_eq!(reparsed, git);
1234    }
1235
1236    // --- Schema tests ---
1237
1238    #[test]
1239    fn schema_field_count() {
1240        assert_eq!(Git::schema().len(), 17);
1241    }
1242
1243    #[test]
1244    fn schema_no_duplicate_labels() {
1245        let schema = Git::schema();
1246        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1247        labels.sort();
1248        let before = labels.len();
1249        labels.dedup();
1250        assert_eq!(before, labels.len(), "duplicate labels in GIT schema");
1251    }
1252
1253    #[test]
1254    fn schema_sub_schema_field_counts() {
1255        let schema = Git::schema();
1256
1257        let creature = schema
1258            .iter()
1259            .find(|f| f.label == "Creature List")
1260            .expect("schema must contain Creature List");
1261        assert_eq!(
1262            creature.children.expect("Creature List has children").len(),
1263            8
1264        );
1265
1266        let item = schema
1267            .iter()
1268            .find(|f| f.label == "List")
1269            .expect("schema must contain List");
1270        assert_eq!(item.children.expect("List has children").len(), 8);
1271
1272        let door = schema
1273            .iter()
1274            .find(|f| f.label == "Door List")
1275            .expect("schema must contain Door List");
1276        assert_eq!(door.children.expect("Door List has children").len(), 12);
1277
1278        let placeable = schema
1279            .iter()
1280            .find(|f| f.label == "Placeable List")
1281            .expect("schema must contain Placeable List");
1282        assert_eq!(
1283            placeable
1284                .children
1285                .expect("Placeable List has children")
1286                .len(),
1287            7
1288        );
1289
1290        let waypoint = schema
1291            .iter()
1292            .find(|f| f.label == "WaypointList")
1293            .expect("schema must contain WaypointList");
1294        assert_eq!(
1295            waypoint.children.expect("WaypointList has children").len(),
1296            17
1297        );
1298
1299        let sound = schema
1300            .iter()
1301            .find(|f| f.label == "SoundList")
1302            .expect("schema must contain SoundList");
1303        assert_eq!(sound.children.expect("SoundList has children").len(), 6);
1304
1305        let trigger = schema
1306            .iter()
1307            .find(|f| f.label == "TriggerList")
1308            .expect("schema must contain TriggerList");
1309        assert_eq!(
1310            trigger.children.expect("TriggerList has children").len(),
1311            14
1312        );
1313
1314        let store = schema
1315            .iter()
1316            .find(|f| f.label == "StoreList")
1317            .expect("schema must contain StoreList");
1318        assert_eq!(store.children.expect("StoreList has children").len(), 8);
1319
1320        let encounter = schema
1321            .iter()
1322            .find(|f| f.label == "Encounter List")
1323            .expect("schema must contain Encounter List");
1324        assert_eq!(
1325            encounter
1326                .children
1327                .expect("Encounter List has children")
1328                .len(),
1329            7
1330        );
1331
1332        let area_effect = schema
1333            .iter()
1334            .find(|f| f.label == "AreaEffectList")
1335            .expect("schema must contain AreaEffectList");
1336        assert_eq!(
1337            area_effect
1338                .children
1339                .expect("AreaEffectList has children")
1340                .len(),
1341            29
1342        );
1343
1344        let area_props = schema
1345            .iter()
1346            .find(|f| f.label == "AreaProperties")
1347            .expect("schema must contain AreaProperties");
1348        assert_eq!(
1349            area_props
1350                .children
1351                .expect("AreaProperties has children")
1352                .len(),
1353            18
1354        );
1355
1356        let area_map = schema
1357            .iter()
1358            .find(|f| f.label == "AreaMap")
1359            .expect("schema must contain AreaMap");
1360        assert_eq!(area_map.children.expect("AreaMap has children").len(), 4);
1361
1362        let camera = schema
1363            .iter()
1364            .find(|f| f.label == "CameraList")
1365            .expect("schema must contain CameraList");
1366        assert_eq!(camera.children.expect("CameraList has children").len(), 7);
1367    }
1368
1369    #[test]
1370    fn schema_trigger_geometry_has_children() {
1371        let schema = Git::schema();
1372        let trigger = schema
1373            .iter()
1374            .find(|f| f.label == "TriggerList")
1375            .expect("schema must contain TriggerList");
1376        let trigger_children = trigger.children.expect("TriggerList has children");
1377        let geometry = trigger_children
1378            .iter()
1379            .find(|f| f.label == "Geometry")
1380            .expect("TriggerList must contain Geometry");
1381        assert_eq!(geometry.children.expect("Geometry has children").len(), 3);
1382    }
1383
1384    #[test]
1385    fn schema_encounter_nested_lists_have_children() {
1386        let schema = Git::schema();
1387        let encounter = schema
1388            .iter()
1389            .find(|f| f.label == "Encounter List")
1390            .expect("schema must contain Encounter List");
1391        let encounter_children = encounter.children.expect("Encounter List has children");
1392
1393        let geometry = encounter_children
1394            .iter()
1395            .find(|f| f.label == "Geometry")
1396            .expect("Encounter List must contain Geometry");
1397        assert_eq!(geometry.children.expect("Geometry has children").len(), 3);
1398
1399        let spawn_points = encounter_children
1400            .iter()
1401            .find(|f| f.label == "SpawnPointList")
1402            .expect("Encounter List must contain SpawnPointList");
1403        assert_eq!(
1404            spawn_points
1405                .children
1406                .expect("SpawnPointList has children")
1407                .len(),
1408            4
1409        );
1410    }
1411
1412    #[test]
1413    fn schema_var_table_has_no_children() {
1414        let schema = Git::schema();
1415        let var_table = schema
1416            .iter()
1417            .find(|f| f.label == "VarTable")
1418            .expect("schema must contain VarTable");
1419        assert!(var_table.children.is_none());
1420    }
1421}