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 rakata_formats::gff::{upsert_field, GffLabel};
18use rakata_formats::gff_label;
19use rakata_formats::schema::FromGff;
20use rakata_formats::schema::GffScalar;
21use rakata_formats::GENERIC_FILE_TYPE;
22use rakata_formats::{
23    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffModel, GffStruct, GffValue,
24};
25use thiserror::Error;
26
27use crate::shared::ObjectId;
28
29use crate::git::creature::SavedCreature;
30use crate::git::door::SavedDoor;
31use crate::git::item::SavedItem;
32use crate::git::placeable::SavedPlaceable;
33use crate::git::sound::SavedSound;
34use crate::git::store::SavedStore;
35use crate::git::trigger::SavedTrigger;
36pub mod area_effect;
37pub mod area_properties;
38pub mod blocks;
39pub mod camera;
40pub mod creature;
41pub mod door;
42pub mod encounter;
43pub mod item;
44pub mod placeable;
45pub mod sound;
46pub mod store;
47pub mod trigger;
48pub mod waypoint;
49
50pub use area_effect::{GitAreaEffect, GitAreaEffectShape};
51pub use area_properties::GitAreaProperties;
52pub use camera::GitCamera;
53pub use creature::GitCreature;
54pub use creature::GitCreatureCommon;
55pub use door::GitDoor;
56pub use door::GitDoorCommon;
57pub use encounter::{GitEncounter, GitEncounterPoint, GitSpawnPoint};
58pub use item::GitItem;
59pub use placeable::GitPlaceable;
60pub use placeable::GitPlaceableCommon;
61pub use sound::GitSound;
62pub use sound::GitSoundCommon;
63pub use store::GitStore;
64pub use trigger::GitTrigger;
65pub use trigger::GitTriggerCommon;
66pub use waypoint::{GitMapNote, GitWaypoint};
67
68// =========================================================================
69// Instance sub-types
70// =========================================================================
71
72/// The two forms an object list in a `GIT` can take.
73///
74/// `UseTemplates` decides which, for every object list at once: `1` in a
75/// module's static `.git`, `0` inside a save. What each setting changes about
76/// how an element is read, and what a missing field falls back to, is in
77/// `docs/src/formats/save/index.md`.
78///
79/// The flag is not kept as a separate field on [`Git`], because it describes
80/// exactly this choice and storing both would let them disagree.
81///
82/// # Three lists are not one of these
83///
84/// Waypoints, because `LoadWaypoints` ignores `UseTemplates` and always
85/// reads inline data, so there is nothing to select between. Area effects,
86/// because no loader in the binary opens a template for one, so that list is
87/// only ever the saved form. Encounters, because their saved field set is
88/// not modelled yet; see [`Git::encounters`] for why.
89#[derive(Debug, Clone, PartialEq)]
90pub enum GitObjects<S, T> {
91    /// `UseTemplates = 1`: sparse placements referencing blueprints.
92    Static(Vec<S>),
93    /// `UseTemplates = 0`: full snapshots, as a savegame stores them.
94    Saved(Vec<T>),
95}
96
97impl<S, T> Default for GitObjects<S, T> {
98    /// Matches the engine's loader default of `0` for an absent
99    /// `UseTemplates`.
100    fn default() -> Self {
101        Self::Saved(Vec::new())
102    }
103}
104
105impl<S, T> GitObjects<S, T> {
106    /// Returns whether this list references blueprints rather than carrying
107    /// snapshots, which is what `UseTemplates` records.
108    pub fn uses_templates(&self) -> bool {
109        matches!(self, Self::Static(_))
110    }
111
112    /// Returns the number of entries, whichever form they take.
113    pub fn len(&self) -> usize {
114        match self {
115            Self::Static(entries) => entries.len(),
116            Self::Saved(entries) => entries.len(),
117        }
118    }
119
120    /// Returns whether the list holds no entries.
121    pub fn is_empty(&self) -> bool {
122        self.len() == 0
123    }
124
125    /// Returns the static placements, or `None` when the list is saved content.
126    pub fn as_static(&self) -> Option<&[S]> {
127        match self {
128            Self::Static(entries) => Some(entries),
129            Self::Saved(_) => None,
130        }
131    }
132
133    /// Returns the saved snapshots, or `None` when the list is static content.
134    pub fn as_saved(&self) -> Option<&[T]> {
135        match self {
136            Self::Saved(entries) => Some(entries),
137            Self::Static(_) => None,
138        }
139    }
140
141    /// Mutable view of the static placements, or `None` for saved content.
142    pub fn as_static_mut(&mut self) -> Option<&mut Vec<S>> {
143        match self {
144            Self::Static(entries) => Some(entries),
145            Self::Saved(_) => None,
146        }
147    }
148
149    /// Mutable view of the saved snapshots, or `None` for static content.
150    pub fn as_saved_mut(&mut self) -> Option<&mut Vec<T>> {
151        match self {
152            Self::Saved(entries) => Some(entries),
153            Self::Static(_) => None,
154        }
155    }
156}
157
158/// A `Creature List` in either of its two forms.
159pub type GitCreatures = GitObjects<GitCreature, SavedCreature>;
160
161/// A `Door List` in either of its two forms.
162pub type GitDoors = GitObjects<GitDoor, SavedDoor>;
163
164/// A `Placeable List` in either of its two forms.
165pub type GitPlaceables = GitObjects<GitPlaceable, SavedPlaceable>;
166
167/// A `List` of loose area items in either of its two forms.
168pub type GitItems = GitObjects<GitItem, SavedItem>;
169
170/// A `SoundList` in either of its two forms.
171pub type GitSounds = GitObjects<GitSound, SavedSound>;
172
173/// A `StoreList` in either of its two forms.
174pub type GitStores = GitObjects<GitStore, SavedStore>;
175
176/// A `TriggerList` in either of its two forms.
177pub type GitTriggers = GitObjects<GitTrigger, SavedTrigger>;
178
179// =========================================================================
180// Non-instance sub-types
181// =========================================================================
182
183// =========================================================================
184// Root GIT struct
185// =========================================================================
186
187/// The bitmap payload, named so the entry can reach its GFF mapping.
188type AreaMapBytes = Vec<u8>;
189
190/// The area's explored-map bitmap, which the view declares and does not model.
191///
192/// Four labels with no member: the bitmap is save state a projection has
193/// nothing to say about, and leaving them out would make an unrecognised
194/// field check fire on a correct save.
195#[derive(Debug, Clone, PartialEq, GffModel)]
196#[gff_entry(AreaMapResX, wire = i32, unexamined)]
197#[gff_entry(AreaMapResY, wire = i32, unexamined)]
198#[gff_entry(AreaMapDataSize, wire = u32, unexamined)]
199#[gff_entry(AreaMapData, wire = AreaMapBytes, unexamined)]
200pub struct GitAreaMap {}
201
202/// Typed GIT model built from/to [`Gff`] data.
203///
204/// GIT is the area instance container. It places object instances by
205/// referencing templates and providing position/orientation data. All
206/// engine-read fields are typed; roundtrip is fully lossless for typed
207/// fields.
208#[derive(Debug, Clone, PartialEq, GffModel)]
209#[gff_entry(UseTemplates, wire = u8, stamped)]
210#[gff_entry(VarTable, container = list, unexamined)]
211pub struct Git {
212    /// The explored-map bitmap (`AreaMap`), declared and not modelled.
213    ///
214    /// Save-only. `AreaMapData` appears in every save fixture and in no
215    /// module GIT the install ships, so writing the block unconditionally
216    /// would put an explored map on an area nobody has walked.
217    #[gff(AreaMap, unexamined, nested = GitAreaMap, optional)]
218    pub area_map: Option<GitAreaMap>,
219    /// Current weather (`CurrentWeather`). If area is an interior, engine forcibly overrides to 0xFF.
220    #[gff(CurrentWeather, unexamined)]
221    pub current_weather: u8,
222    /// Weather started flag (`WeatherStarted`). If area is an interior, engine forcibly overrides to 0 (false).
223    #[gff(WeatherStarted, unexamined)]
224    pub weather_started: bool,
225    /// Waypoint instances (`WaypointList`).
226    #[gff(WaypointList, not_a_constant, list = GitWaypoint, element_id = 5)]
227    pub waypoints: Vec<GitWaypoint>,
228    /// Encounter instances (`Encounter List`).
229    ///
230    /// Read as static placements whatever `UseTemplates` says, which is
231    /// wrong for a savegame `GIT`: a saved encounter comes back with an
232    /// empty `TemplateResRef` rather than its spawned state. Encounters are
233    /// the one object list still in that position. They are held back
234    /// because there is nothing to model them against: every save in
235    /// `fixtures/saves/` has an empty `Encounter List`, and unlike area
236    /// effects the engine's saved encounter field set is not written down in
237    /// `docs/src/formats/gff/git.md` either. Modelling it from `LoadEncounters`
238    /// alone would ship a field set with no way to check it.
239    #[gff("Encounter List", not_a_constant, list = GitEncounter, element_id = 7)]
240    pub encounters: Vec<GitEncounter>,
241    /// Area-of-effect instances (`AreaEffectList`).
242    #[gff(AreaEffectList, not_a_constant, list = GitAreaEffect, element_id = 13)]
243    pub area_effects: Vec<GitAreaEffect>,
244    /// Area-level properties (`AreaProperties`).
245    #[gff(AreaProperties, unexamined, nested = GitAreaProperties, optional)]
246    pub area_properties: Option<GitAreaProperties>,
247    /// Static cameras (`CameraList`). Engine load failure occurs if array contains 51 or more entries.
248    #[gff(
249        CameraList,
250        not_a_constant,
251        list = GitCamera,
252        element_id = unenforced(
253            14,
254            "LoadPlaceableCameras walks the list by position and takes each field \
255             by label, so nothing tests an element's id. Every vanilla file writes \
256             14 and there is no reason to write anything else; what that loader \
257             does check is the list's total length"
258        )
259    )]
260    pub cameras: Vec<GitCamera>,
261    /// Creature List instances, in whichever form the file uses.
262    #[gff(
263        "Creature List",
264        live,
265        not_a_constant,
266        list = GitCreatureCommon,
267        split = UseTemplates(GitCreature, SavedCreature),
268        element_id = 4,
269        manual_read,
270        manual_write
271    )]
272    pub creatures: GitCreatures,
273    /// List instances, in whichever form the file uses.
274    #[gff(
275        List,
276        not_a_constant,
277        list = GitObjectPlacement,
278        split = UseTemplates(GitItem, SavedItem),
279        element_id = 0,
280        manual_read,
281        manual_write
282    )]
283    pub items: GitItems,
284    /// Door List instances, in whichever form the file uses.
285    #[gff(
286        "Door List",
287        not_a_constant,
288        list = GitDoorCommon,
289        split = UseTemplates(GitDoor, SavedDoor),
290        element_id = 8,
291        manual_read,
292        manual_write
293    )]
294    pub doors: GitDoors,
295    /// Placeable List instances, in whichever form the file uses.
296    #[gff(
297        "Placeable List",
298        not_a_constant,
299        list = GitPlaceableCommon,
300        split = UseTemplates(GitPlaceable, SavedPlaceable),
301        element_id = 9,
302        manual_read,
303        manual_write
304    )]
305    pub placeables: GitPlaceables,
306    /// SoundList instances, in whichever form the file uses.
307    #[gff(
308        SoundList,
309        not_a_constant,
310        list = GitSoundCommon,
311        split = UseTemplates(GitSound, SavedSound),
312        element_id = 6,
313        manual_read,
314        manual_write
315    )]
316    pub sounds: GitSounds,
317    /// TriggerList instances, in whichever form the file uses.
318    #[gff(
319        TriggerList,
320        not_a_constant,
321        list = GitTriggerCommon,
322        split = UseTemplates(GitTrigger, SavedTrigger),
323        element_id = 1,
324        manual_read,
325        manual_write
326    )]
327    pub triggers: GitTriggers,
328    /// StoreList instances, in whichever form the file uses.
329    #[gff(
330        StoreList,
331        not_a_constant,
332        list = GitObjectPlacement,
333        split = UseTemplates(GitStore, SavedStore),
334        element_id = 11,
335        manual_read,
336        manual_write
337    )]
338    pub stores: GitStores,
339}
340
341impl Git {
342    /// Creates an empty GIT value.
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Builds typed GIT data from a parsed GFF container.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`GitError::UnsupportedFileType`] when the container is not a
352    /// `GIT ` or a bare `GFF `.
353    pub fn from_gff(gff: &Gff) -> Result<Self, GitError> {
354        if gff.file_type != <Git as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
355            return Err(GitError::UnsupportedFileType(gff.file_type));
356        }
357        let root = &gff.root;
358
359        // One resolution for every object list, from the struct they all sit
360        // on. The selector is a sibling of the lists rather than a field of an
361        // element, which is why no element reader takes a form parameter.
362        let templated = root
363            .field("UseTemplates")
364            .and_then(<bool as GffScalar>::from_gff_value)
365            .unwrap_or(false);
366
367        Ok(Self {
368            creatures: read_objects(root, "Creature List", templated),
369            items: read_objects(root, "List", templated),
370            doors: read_objects(root, "Door List", templated),
371            placeables: read_objects(root, "Placeable List", templated),
372            sounds: read_objects(root, "SoundList", templated),
373            triggers: read_objects(root, "TriggerList", templated),
374            stores: read_objects(root, "StoreList", templated),
375            ..Self::read_declared(root)
376        })
377    }
378
379    /// Converts this typed GIT value into a GFF container.
380    pub fn to_gff(&self) -> Gff {
381        let mut root = GffStruct::new(-1);
382        self.write_declared(&mut root);
383
384        // Derived from the lists rather than held beside them, so the flag
385        // and the contents cannot disagree. A static module `.git` sets it; a
386        // savegame omits it, because the engine's own writer never emits one
387        // and its loader defaults to clear.
388        if self.creatures.uses_templates() {
389            upsert_field(&mut root, gff_label!("UseTemplates"), GffValue::UInt8(1));
390        }
391
392        write_objects(&mut root, gff_label!("Creature List"), &self.creatures, 4);
393        write_objects(&mut root, gff_label!("List"), &self.items, 0);
394        write_objects(&mut root, gff_label!("Door List"), &self.doors, 8);
395        write_objects(&mut root, gff_label!("Placeable List"), &self.placeables, 9);
396        write_objects(&mut root, gff_label!("SoundList"), &self.sounds, 6);
397        write_objects(&mut root, gff_label!("TriggerList"), &self.triggers, 1);
398        write_objects(&mut root, gff_label!("StoreList"), &self.stores, 11);
399
400        Gff::new(*b"GIT ", root)
401    }
402}
403
404/// Reads one object list in whichever form the selector chose.
405///
406/// The arms are separate types, so the read is one branch and then the
407/// ordinary element read on whichever side it landed.
408fn read_objects<S, T>(root: &GffStruct, label: &str, templated: bool) -> GitObjects<S, T>
409where
410    S: GitElement,
411    T: GitElement,
412{
413    let Some(GffValue::List(elements)) = root.field(label) else {
414        return if templated {
415            GitObjects::Static(Vec::new())
416        } else {
417            GitObjects::Saved(Vec::new())
418        };
419    };
420    if templated {
421        GitObjects::Static(elements.iter().map(S::read).collect())
422    } else {
423        GitObjects::Saved(elements.iter().map(T::read).collect())
424    }
425}
426
427/// Writes one object list, stamping the id its loader tests against.
428fn write_objects<S, T>(root: &mut GffStruct, label: GffLabel, objects: &GitObjects<S, T>, id: i32)
429where
430    S: GitElement,
431    T: GitElement,
432{
433    let elements: Vec<GffStruct> = match objects {
434        GitObjects::Static(entries) => entries.iter().map(|e| e.write(id)).collect(),
435        GitObjects::Saved(entries) => entries.iter().map(|e| e.write(id)).collect(),
436    };
437    upsert_field(root, label, GffValue::List(elements));
438}
439
440/// One arm's element codec, so a list can read or write either side without
441/// naming which.
442///
443/// Both halves are what the derive already generates for a list element; this
444/// only gives the two arms one name so the list can be generic over them.
445pub trait GitElement: Sized {
446    /// Reads one element.
447    fn read(structure: &GffStruct) -> Self;
448    /// Writes one element into a struct carrying the list's own id.
449    fn write(&self, id: i32) -> GffStruct;
450}
451
452/// Where an item entry or a store entry sits, whichever form the entry takes.
453///
454/// One type for two lists rather than one each: the sets are the same, they
455/// agree on every audited axis, and the declarations the extraction produced
456/// for them are identical. A later measurement that parts them breaks the
457/// share rather than quietly answering for the wrong list.
458///
459/// `ObjectId` is deliberately not here, because it is not shared: every
460/// saved form carries it, no static form does, and the party stash carries it
461/// on none of its items. It is declared by the saved arms instead, so a list
462/// that has no runtime id does not gain one on the way back out.
463#[derive(Debug, Clone, PartialEq, GffModel)]
464pub struct GitObjectPlacement {
465    /// Runtime object id (`ObjectId`), which not every list carries.
466    ///
467    /// `None` where the file has no label, which is the state the six fields
468    /// below never reach: they are on every element of every list measured and
469    /// this one is not. Static forms carry it nowhere, and among saved forms
470    /// the party's shared stash carries it on none of its items while every
471    /// other item list carries it on all of theirs.
472    ///
473    /// Held as the absence rather than resolved to a value because writing it
474    /// back is what the resolution would cost. The engine's own answer for an
475    /// absent label is on the schema entry beside this one, and it is
476    /// `INVALID` rather than zero.
477    #[gff(ObjectId, stamped = ObjectId::INVALID, optional = ObjectId)]
478    pub object_id: Option<ObjectId>,
479    /// X position (`XPosition`).
480    #[gff(XPosition, unexamined)]
481    pub x_position: f32,
482    /// Y position (`YPosition`).
483    #[gff(YPosition, unexamined)]
484    pub y_position: f32,
485    /// Z position (`ZPosition`).
486    #[gff(ZPosition, unexamined)]
487    pub z_position: f32,
488    /// X orientation (`XOrientation`).
489    #[gff(XOrientation, unexamined)]
490    pub x_orientation: f32,
491    /// Y orientation (`YOrientation`).
492    #[gff(YOrientation, unexamined)]
493    pub y_orientation: f32,
494    /// Z orientation (`ZOrientation`).
495    /// Z orientation (`ZOrientation`), which only a saved form carries.
496    ///
497    /// The same two-versus-three split the creature block has: a static item or
498    /// store placement carries `XOrientation` and `YOrientation` and no third,
499    /// and every saved one carries all three.
500    #[gff(ZOrientation, unexamined, optional = f32)]
501    pub z_orientation: Option<f32>,
502}
503
504/// Every arm type, each reaching the pair its own derive supplied.
505macro_rules! git_element {
506    ($($ty:ty),* $(,)?) => {$(
507        impl GitElement for $ty {
508            fn read(structure: &GffStruct) -> Self {
509                <$ty>::read_element(structure)
510            }
511
512            fn write(&self, id: i32) -> GffStruct {
513                let mut element = GffStruct::new(id);
514                <$ty>::write_element(self, &mut element);
515                element
516            }
517        }
518    )*};
519}
520
521git_element!(
522    GitCreature,
523    SavedCreature,
524    GitItem,
525    SavedItem,
526    GitDoor,
527    SavedDoor,
528    GitPlaceable,
529    SavedPlaceable,
530    GitSound,
531    SavedSound,
532    GitTrigger,
533    SavedTrigger,
534    GitStore,
535    SavedStore,
536);
537
538/// Errors produced while reading or writing typed GIT data.
539#[derive(Debug, Error)]
540pub enum GitError {
541    /// Source file type is not supported by this parser.
542    #[error("unsupported GIT file type: {0:?}")]
543    UnsupportedFileType([u8; 4]),
544    /// Underlying GFF parser/writer error.
545    #[error(transparent)]
546    Gff(#[from] GffBinaryError),
547}
548
549/// Reads typed GIT data from a reader at the current stream position.
550///
551/// # Errors
552///
553/// [`GitError::Gff`] when the stream is not a readable GFF, and
554/// [`GitError::UnsupportedFileType`] when it is a GFF of some other format,
555/// carrying the fourcc that was found.
556#[cfg_attr(
557    feature = "tracing",
558    tracing::instrument(level = "debug", skip(reader))
559)]
560pub fn read_git<R: Read>(reader: &mut R) -> Result<Git, GitError> {
561    let gff = read_gff(reader)?;
562    Git::from_gff(&gff)
563}
564
565/// Reads typed GIT data directly from bytes.
566///
567/// # Errors
568///
569/// [`GitError::Gff`] when `bytes` are not a readable GFF, and
570/// [`GitError::UnsupportedFileType`] when they are a GFF of some other format,
571/// carrying the fourcc that was found.
572#[cfg_attr(
573    feature = "tracing",
574    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
575)]
576pub fn read_git_from_bytes(bytes: &[u8]) -> Result<Git, GitError> {
577    let gff = read_gff_from_bytes(bytes)?;
578    Git::from_gff(&gff)
579}
580
581/// Authors the GIT file the typed view describes, into a writer.
582///
583/// # Errors
584///
585/// [`GitError::Gff`] when the writer fails or a value will not encode. The
586/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
587/// this side.
588#[cfg_attr(
589    feature = "tracing",
590    tracing::instrument(level = "debug", skip(writer, git))
591)]
592pub fn author_git<W: Write>(writer: &mut W, git: &Git) -> Result<(), GitError> {
593    let gff = git.to_gff();
594    write_gff(writer, &gff)?;
595    Ok(())
596}
597
598/// Authors the GIT file the typed view describes, as bytes.
599///
600/// # Errors
601///
602/// [`GitError::Gff`] when a value will not encode. Writing into a `Vec` has no
603/// I/O to fail at.
604#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(git)))]
605pub fn author_git_to_vec(git: &Git) -> Result<Vec<u8>, GitError> {
606    let mut cursor = Cursor::new(Vec::new());
607    author_git(&mut cursor, git)?;
608    Ok(cursor.into_inner())
609}
610
611// =========================================================================
612// Leaf sub-schemas (no nested children)
613// =========================================================================
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use rakata_formats::schema::{Absent, Field, GffScalar, HasSchema, Resolution, Shape};
619
620    /// One value written into an element struct carrying the list's own id.
621    macro_rules! written {
622        ($value:expr, $id:expr) => {{
623            let mut element = GffStruct::new($id);
624            $value.write_element(&mut element);
625            element
626        }};
627    }
628
629    use rakata_core::{ResRef, StrRef};
630    use rakata_formats::GffLocalizedString;
631
632    /// Build a minimal GIT GFF for testing.
633    fn make_test_git_gff() -> Gff {
634        let mut root = GffStruct::new(-1);
635        root.push_field(gff_label!("CurrentWeather"), GffValue::UInt8(0));
636        root.push_field(gff_label!("WeatherStarted"), GffValue::UInt8(0));
637        root.push_field(gff_label!("UseTemplates"), GffValue::UInt8(1));
638
639        // One creature.
640        let mut creature = GffStruct::new(4);
641        creature.push_field(
642            gff_label!("TemplateResRef"),
643            GffValue::resref_lit("n_darthbandon"),
644        );
645        creature.push_field(gff_label!("XPosition"), GffValue::Single(10.0));
646        creature.push_field(gff_label!("YPosition"), GffValue::Single(20.0));
647        creature.push_field(gff_label!("ZPosition"), GffValue::Single(0.5));
648        creature.push_field(gff_label!("XOrientation"), GffValue::Single(0.0));
649        creature.push_field(gff_label!("YOrientation"), GffValue::Single(1.0));
650        creature.push_field(gff_label!("ZOrientation"), GffValue::Single(0.0));
651        creature.push_field(gff_label!("ObjectId"), GffValue::UInt32(0));
652        root.push_field(gff_label!("Creature List"), GffValue::List(vec![creature]));
653
654        // One placeable.
655        let mut placeable = GffStruct::new(9);
656        placeable.push_field(
657            gff_label!("TemplateResRef"),
658            GffValue::resref_lit("plc_footlkr"),
659        );
660        placeable.push_field(gff_label!("Bearing"), GffValue::Single(1.57));
661        placeable.push_field(gff_label!("X"), GffValue::Single(15.0));
662        placeable.push_field(gff_label!("Y"), GffValue::Single(25.0));
663        placeable.push_field(gff_label!("Z"), GffValue::Single(0.0));
664        placeable.push_field(gff_label!("ObjectId"), GffValue::UInt32(0));
665        root.push_field(
666            gff_label!("Placeable List"),
667            GffValue::List(vec![placeable]),
668        );
669
670        // One trigger with geometry.
671        let mut trigger = GffStruct::new(1);
672        trigger.push_field(
673            gff_label!("TemplateResRef"),
674            GffValue::resref_lit("newtransition9"),
675        );
676        trigger.push_field(
677            gff_label!("LinkedToModule"),
678            GffValue::resref_lit("tar_m02aa"),
679        );
680        trigger.push_field(
681            gff_label!("TransitionDestin"),
682            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(12345))),
683        );
684        trigger.push_field(gff_label!("LinkedTo"), GffValue::String("wp_target".into()));
685        trigger.push_field(gff_label!("LinkedToFlags"), GffValue::UInt8(2));
686        trigger.push_field(gff_label!("XPosition"), GffValue::Single(5.0));
687        trigger.push_field(gff_label!("YPosition"), GffValue::Single(5.0));
688        trigger.push_field(gff_label!("ZPosition"), GffValue::Single(0.0));
689        let mut pt0 = GffStruct::new(0);
690        pt0.push_field(gff_label!("PointX"), GffValue::Single(0.0));
691        pt0.push_field(gff_label!("PointY"), GffValue::Single(0.0));
692        pt0.push_field(gff_label!("PointZ"), GffValue::Single(0.0));
693        let mut pt1 = GffStruct::new(0);
694        pt1.push_field(gff_label!("PointX"), GffValue::Single(5.0));
695        pt1.push_field(gff_label!("PointY"), GffValue::Single(0.0));
696        pt1.push_field(gff_label!("PointZ"), GffValue::Single(0.0));
697        let mut pt2 = GffStruct::new(0);
698        pt2.push_field(gff_label!("PointX"), GffValue::Single(5.0));
699        pt2.push_field(gff_label!("PointY"), GffValue::Single(5.0));
700        pt2.push_field(gff_label!("PointZ"), GffValue::Single(0.0));
701        trigger.push_field(gff_label!("Geometry"), GffValue::List(vec![pt0, pt1, pt2]));
702        trigger.push_field(gff_label!("ObjectId"), GffValue::UInt32(0));
703        root.push_field(gff_label!("TriggerList"), GffValue::List(vec![trigger]));
704
705        // AreaProperties.
706        let mut props = GffStruct::new(0);
707        props.push_field(gff_label!("Unescapable"), GffValue::UInt8(0));
708        props.push_field(gff_label!("StealthXPMax"), GffValue::UInt32(100));
709        props.push_field(gff_label!("StealthXPCurrent"), GffValue::UInt32(0));
710        props.push_field(gff_label!("StealthXPLoss"), GffValue::UInt32(10));
711        props.push_field(gff_label!("StealthXPEnabled"), GffValue::UInt8(1));
712        props.push_field(gff_label!("TransPending"), GffValue::UInt8(0));
713        props.push_field(gff_label!("TransPendNextID"), GffValue::UInt8(0));
714        props.push_field(gff_label!("TransPendCurrID"), GffValue::UInt8(0));
715        props.push_field(gff_label!("SunFogColor"), GffValue::UInt32(0x00ABCDEF));
716        props.push_field(gff_label!("MusicDelay"), GffValue::Int32(6000));
717        props.push_field(gff_label!("MusicDay"), GffValue::Int32(48));
718        props.push_field(gff_label!("MusicNight"), GffValue::Int32(49));
719        props.push_field(gff_label!("MusicBattle"), GffValue::Int32(25));
720        props.push_field(gff_label!("AmbientSndDay"), GffValue::Int32(12));
721        props.push_field(gff_label!("AmbientSndNight"), GffValue::Int32(13));
722        props.push_field(gff_label!("AmbientSndDayVol"), GffValue::Int32(50));
723        props.push_field(gff_label!("AmbientSndNitVol"), GffValue::Int32(40));
724        root.push_field(
725            gff_label!("AreaProperties"),
726            GffValue::Struct(Box::new(props)),
727        );
728
729        // Empty lists for remaining types.
730        root.push_field(gff_label!("List"), GffValue::List(vec![]));
731        root.push_field(gff_label!("Door List"), GffValue::List(vec![]));
732        root.push_field(gff_label!("WaypointList"), GffValue::List(vec![]));
733        root.push_field(gff_label!("SoundList"), GffValue::List(vec![]));
734        root.push_field(gff_label!("StoreList"), GffValue::List(vec![]));
735        root.push_field(gff_label!("Encounter List"), GffValue::List(vec![]));
736        root.push_field(gff_label!("AreaEffectList"), GffValue::List(vec![]));
737        root.push_field(gff_label!("CameraList"), GffValue::List(vec![]));
738
739        Gff::new(*b"GIT ", root)
740    }
741
742    #[test]
743    fn reads_root_scalars() {
744        let gff = make_test_git_gff();
745        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
746
747        assert_eq!(git.current_weather, 0);
748        assert!(!git.weather_started);
749        assert!(git.creatures.uses_templates());
750    }
751
752    #[test]
753    fn reads_creature_list() {
754        let gff = make_test_git_gff();
755        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
756
757        assert_eq!(git.creatures.len(), 1);
758        let statics = git
759            .creatures
760            .as_static()
761            .expect("the fixture sets UseTemplates = 1");
762        let c = &statics[0];
763        assert_eq!(c.template_resref, "n_darthbandon");
764        assert!((c.common.x_position - 10.0).abs() < f32::EPSILON);
765        assert!((c.common.y_position - 20.0).abs() < f32::EPSILON);
766        assert!((c.common.y_orientation - 1.0).abs() < f32::EPSILON);
767    }
768
769    fn sample_area_effect() -> GitAreaEffect {
770        GitAreaEffect {
771            tag: "aoe_stasis".to_string(),
772            object_id: ObjectId::INVALID,
773            area_effect_id: 12,
774            spell_id: 44,
775            spell_save_dc: 18,
776            spell_level: 3,
777            shape: GitAreaEffectShape::Circle { radius: 5.0 },
778            duration: 30_000,
779            duration_type: 2,
780            position_x: 1.5,
781            orientation_y: 1.0,
782            ..GitAreaEffect::default()
783        }
784    }
785
786    #[test]
787    fn area_effect_round_trips_through_a_list_element() {
788        let effect = sample_area_effect();
789
790        let parsed = GitAreaEffect::read_element(&written!(effect, 0));
791
792        assert_eq!(parsed, effect);
793    }
794
795    #[test]
796    fn an_area_effect_writes_only_the_dimensions_its_shape_uses() {
797        // The loader reads Radius only for shape 0 and Length/Width only for
798        // shape 1, so writing the other pair puts fields on the object the
799        // engine would never read back.
800        let circle = written!(sample_area_effect(), 0);
801        assert!(circle.field("Radius").is_some());
802        assert!(circle.field("Length").is_none());
803        assert!(circle.field("Width").is_none());
804
805        let rectangle = GitAreaEffect {
806            shape: GitAreaEffectShape::Rectangle {
807                length: 4.0,
808                width: 2.0,
809            },
810            ..sample_area_effect()
811        };
812        let rectangle = written!(rectangle, 13);
813        assert!(rectangle.field("Radius").is_none());
814        assert!(rectangle.field("Length").is_some());
815        assert!(rectangle.field("Width").is_some());
816
817        let shapeless = GitAreaEffect {
818            shape: GitAreaEffectShape::None(7),
819            ..sample_area_effect()
820        };
821        let shapeless = written!(shapeless, 13);
822        assert!(shapeless.field("Radius").is_none());
823        assert!(shapeless.field("Length").is_none());
824        assert_eq!(shapeless.field("Shape"), Some(&GffValue::UInt8(7)));
825    }
826
827    #[test]
828    fn a_door_placement_keeps_its_blueprint_and_transition() {
829        // The view modelled position and bearing only, so a vanilla door
830        // round-tripped into a placement with nothing to instantiate from
831        // and no transition. Both are set in vanilla modules.
832        let mut door = GffStruct::new(8);
833        door.push_field(
834            gff_label!("TemplateResRef"),
835            GffValue::ResRef(ResRef::new("m13aa_door01").expect("valid test resref")),
836        );
837        door.push_field(gff_label!("Tag"), GffValue::String("EnclaveDoor".into()));
838        door.push_field(gff_label!("Bearing"), GffValue::Single(1.57));
839        door.push_field(
840            gff_label!("LinkedToModule"),
841            GffValue::ResRef(ResRef::new("danm14aa").expect("valid test resref")),
842        );
843        door.push_field(
844            gff_label!("LinkedTo"),
845            GffValue::String("from_courtyard".into()),
846        );
847        door.push_field(gff_label!("LinkedToFlags"), GffValue::UInt8(2));
848        door.push_field(
849            gff_label!("TransitionDestin"),
850            GffValue::LocalizedString(GffLocalizedString::new(
851                StrRef::from_index(31982).expect("valid test strref"),
852            )),
853        );
854
855        let parsed = GitDoor::read_element(&door);
856        assert_eq!(parsed.template_resref, "m13aa_door01");
857        assert_eq!(parsed.common.linked_to_module, "danm14aa");
858        assert_eq!(parsed.common.linked_to, "from_courtyard");
859        assert_eq!(parsed.common.linked_to_flags, 2);
860        assert_eq!(
861            parsed.common.transition_destination.string_ref,
862            StrRef::from_index(31982).expect("valid test strref")
863        );
864
865        let written = written!(parsed, 0);
866        for label in [
867            "TemplateResRef",
868            "LinkedToModule",
869            "LinkedTo",
870            "LinkedToFlags",
871            "TransitionDestin",
872        ] {
873            assert_eq!(
874                written.field(label),
875                door.field(label),
876                "{label} must survive the round-trip unchanged"
877            );
878        }
879
880        // `Tag` is the one label here the engine does not read at this path:
881        // a templated door takes its tag from the blueprint with no overlay.
882        // Modelling it would hand callers a field that looks settable and
883        // does nothing, so the view drops it and a raw-tree rule reports it.
884        assert!(
885            written.field("Tag").is_none(),
886            "the placement's Tag is dead and must not be written back"
887        );
888    }
889
890    #[test]
891    fn an_absent_object_id_is_the_invalid_placeholder_on_every_type() {
892        // Zero is a valid object id, so defaulting there claims an unplaced
893        // object refers to whichever object the engine numbered first. Only
894        // the area effect got this right; every other type read zero. No
895        // vanilla static GIT carries ObjectId at all, so every one of them
896        // was affected.
897        //
898        // The types reaching the label through `GitObjectPlacement` hold the
899        // absence instead of resolving it, so that a list carrying no id does
900        // not gain one on write. The audited answer did not move off them: it
901        // is on their schema entry, which the assertion at the end of this
902        // test reads back.
903        let empty = GffStruct::new(0);
904
905        // No `GitCreature` here: the static creature path never reads the
906        // label, so the type does not model it. See `git/creature.rs`.
907        assert_eq!(GitItem::read_element(&empty).common.object_id, None);
908        assert_eq!(GitDoor::read_element(&empty).common.object_id, None);
909        assert_eq!(GitPlaceable::read_element(&empty).common.object_id, None);
910        assert_eq!(GitWaypoint::read_element(&empty).object_id, None);
911        assert_eq!(GitSound::read_element(&empty).common.object_id, None);
912        assert_eq!(GitTrigger::read_element(&empty).common.object_id, None);
913        assert_eq!(GitStore::read_element(&empty).common.object_id, None);
914        assert_eq!(GitEncounter::read_element(&empty).object_id, None);
915        assert_eq!(
916            GitAreaEffect::read_element(&GffStruct::new(13)).object_id,
917            ObjectId::INVALID
918        );
919
920        // The saved forms read the same label through the same instruction,
921        // which runs before the static-versus-save branch, so they cannot
922        // legitimately differ from the placements above. They defaulted to
923        // zero until this was checked rather than assumed.
924        assert_eq!(
925            crate::git::creature::SavedCreature::read_element(&empty).object_id,
926            ObjectId::INVALID
927        );
928        assert_eq!(
929            crate::git::door::SavedDoor::read_element(&empty)
930                .common
931                .object_id,
932            None
933        );
934        assert_eq!(
935            crate::git::item::SavedItem::read_element(&empty)
936                .common
937                .object_id,
938            None
939        );
940        assert_eq!(
941            crate::git::placeable::SavedPlaceable::read_element(&empty)
942                .common
943                .object_id,
944            None
945        );
946        assert_eq!(
947            crate::git::sound::SavedSound::read_element(&empty)
948                .common
949                .object_id,
950            None
951        );
952        assert_eq!(
953            crate::git::store::SavedStore::read_element(&empty)
954                .common
955                .object_id,
956            None
957        );
958        assert_eq!(
959            crate::git::trigger::SavedTrigger::read_element(&empty)
960                .common
961                .object_id,
962            None
963        );
964        // The four types reaching the label through `GitObjectPlacement` hold
965        // the absence, so the audited answer is only on the schema now. If it
966        // is ever dropped there it is gone from the workspace entirely.
967        let entry = GitObjectPlacement::SCHEMA
968            .iter()
969            .find(|field| field.label.as_str() == "ObjectId")
970            .expect("the block declares it");
971        let Absent::Resolves(Resolution::Stamped, value, _) = entry.absent else {
972            panic!("the engine stamps a literal here, and the entry has to say so");
973        };
974        assert_eq!(value.get(), ObjectId::INVALID.to_gff_value());
975    }
976
977    #[test]
978    fn reads_placeable_list() {
979        let gff = make_test_git_gff();
980        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
981
982        assert_eq!(git.placeables.len(), 1);
983        let statics = git
984            .placeables
985            .as_static()
986            .expect("the fixture sets UseTemplates = 1");
987        let p = &statics[0];
988        assert_eq!(p.template_resref, "plc_footlkr");
989        assert!((p.placement.bearing - 1.57).abs() < 0.01);
990        assert!((p.placement.x - 15.0).abs() < f32::EPSILON);
991    }
992
993    #[test]
994    fn reads_trigger_with_geometry() {
995        let gff = make_test_git_gff();
996        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
997
998        assert_eq!(git.triggers.len(), 1);
999        let statics = git
1000            .triggers
1001            .as_static()
1002            .expect("the fixture sets UseTemplates = 1");
1003        let t = &statics[0];
1004        assert_eq!(t.template_resref, "newtransition9");
1005        assert_eq!(t.common.linked_to_module, "tar_m02aa");
1006        assert_eq!(t.common.linked_to, "wp_target");
1007        assert_eq!(t.common.linked_to_flags, 2);
1008        assert_eq!(t.geometry.len(), 3);
1009        assert!((t.geometry[1].point_x - 5.0).abs() < f32::EPSILON);
1010    }
1011
1012    #[test]
1013    fn reads_area_properties() {
1014        let gff = make_test_git_gff();
1015        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1016
1017        let ap = git
1018            .area_properties
1019            .as_ref()
1020            .expect("test GFF includes AreaProperties");
1021        assert!(!ap.unescapable);
1022        assert_eq!(ap.stealth_xp_max, 100);
1023        assert!(ap.stealth_xp_enabled);
1024        assert_eq!(ap.music_day, 48);
1025        assert_eq!(ap.music_night, 49);
1026        assert_eq!(ap.music_battle, 25);
1027        assert_eq!(ap.ambient_snd_day_vol, 50);
1028        assert_eq!(ap.ambient_snd_nit_vol, 40);
1029    }
1030
1031    #[test]
1032    fn all_fields_survive_synthetic_roundtrip() {
1033        let gff = make_test_git_gff();
1034        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1035        let bytes = author_git_to_vec(&git).expect("write succeeds");
1036        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1037
1038        assert_eq!(reparsed, git);
1039    }
1040
1041    #[test]
1042    fn typed_edits_roundtrip_through_gff_writer() {
1043        let gff = make_test_git_gff();
1044        let mut git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1045        let statics = git
1046            .creatures
1047            .as_static_mut()
1048            .expect("the fixture sets UseTemplates = 1");
1049        statics[0].template_resref = ResRef::new("n_sithsoldier").expect("valid test resref");
1050        statics[0].common.x_position = 100.0;
1051        git.area_properties
1052            .as_mut()
1053            .expect("test GFF includes AreaProperties")
1054            .music_day = 99;
1055
1056        let bytes = author_git_to_vec(&git).expect("write succeeds");
1057        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1058
1059        let reparsed_statics = reparsed
1060            .creatures
1061            .as_static()
1062            .expect("round-trip keeps UseTemplates = 1");
1063        assert_eq!(reparsed_statics[0].template_resref, "n_sithsoldier");
1064        assert!((reparsed_statics[0].common.x_position - 100.0).abs() < f32::EPSILON);
1065        assert_eq!(
1066            reparsed
1067                .area_properties
1068                .as_ref()
1069                .expect("roundtripped GIT must have AreaProperties")
1070                .music_day,
1071            99
1072        );
1073    }
1074
1075    #[test]
1076    fn read_git_from_reader_matches_bytes_path() {
1077        let gff = make_test_git_gff();
1078        let bytes = {
1079            let mut c = Cursor::new(Vec::new());
1080            write_gff(&mut c, &gff).expect("GFF write to cursor always succeeds");
1081            c.into_inner()
1082        };
1083
1084        let mut cursor = Cursor::new(&bytes);
1085        let via_reader = read_git(&mut cursor).expect("reader parse succeeds");
1086        let via_bytes = read_git_from_bytes(&bytes).expect("bytes parse succeeds");
1087
1088        assert_eq!(via_reader, via_bytes);
1089    }
1090
1091    #[test]
1092    fn rejects_non_git_file_type() {
1093        let mut gff = make_test_git_gff();
1094        gff.file_type = *b"UTW ";
1095
1096        let err = Git::from_gff(&gff).expect_err("UTW must be rejected as GIT input");
1097        assert!(matches!(
1098            err,
1099            GitError::UnsupportedFileType(ft) if ft == *b"UTW "
1100        ));
1101    }
1102
1103    #[test]
1104    fn write_git_matches_direct_gff_writer() {
1105        let gff = make_test_git_gff();
1106        let git = Git::from_gff(&gff).expect("valid GIT GFF must parse");
1107
1108        let via_typed = author_git_to_vec(&git).expect("typed write succeeds");
1109
1110        let mut direct = Cursor::new(Vec::new());
1111        write_gff(&mut direct, &git.to_gff()).expect("direct write succeeds");
1112
1113        assert_eq!(via_typed, direct.into_inner());
1114    }
1115
1116    // --- Fixture tests ---
1117
1118    const TEST_GIT: &[u8] = include_bytes!(concat!(
1119        env!("CARGO_MANIFEST_DIR"),
1120        "/../../fixtures/test.git"
1121    ));
1122    const K1_GIT: &[u8] = include_bytes!(concat!(
1123        env!("CARGO_MANIFEST_DIR"),
1124        "/../../fixtures/k1_same_git_test.git"
1125    ));
1126
1127    #[test]
1128    fn parses_test_git_fixture() {
1129        let git = read_git_from_bytes(TEST_GIT).expect("test.git must parse");
1130        // Should have at least some instance data.
1131        let total = git.creatures.len()
1132            + git.items.len()
1133            + git.doors.len()
1134            + git.placeables.len()
1135            + git.waypoints.len()
1136            + git.sounds.len()
1137            + git.triggers.len()
1138            + git.stores.len()
1139            + git.encounters.len()
1140            + git.area_effects.len();
1141        assert!(total > 0, "fixture should have some instance data");
1142    }
1143
1144    #[test]
1145    fn parses_k1_git_fixture() {
1146        let git = read_git_from_bytes(K1_GIT).expect("k1 GIT must parse");
1147        let total = git.creatures.len()
1148            + git.items.len()
1149            + git.doors.len()
1150            + git.placeables.len()
1151            + git.waypoints.len()
1152            + git.sounds.len()
1153            + git.triggers.len()
1154            + git.stores.len()
1155            + git.encounters.len()
1156            + git.area_effects.len();
1157        assert!(total > 0, "K1 fixture should have some instance data");
1158    }
1159
1160    #[test]
1161    fn all_fields_survive_typed_roundtrip() {
1162        let git = read_git_from_bytes(TEST_GIT).expect("test.git must parse");
1163        let bytes = author_git_to_vec(&git).expect("write succeeds");
1164        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1165
1166        assert_eq!(reparsed, git);
1167    }
1168
1169    #[test]
1170    fn all_fields_survive_typed_roundtrip_k1() {
1171        let git = read_git_from_bytes(K1_GIT).expect("k1 GIT must parse");
1172        let bytes = author_git_to_vec(&git).expect("write succeeds");
1173        let reparsed = read_git_from_bytes(&bytes).expect("reparse succeeds");
1174
1175        assert_eq!(reparsed, git);
1176    }
1177
1178    // --- Schema tests ---
1179
1180    #[test]
1181    fn schema_field_count() {
1182        assert_eq!(Git::schema().len(), 17);
1183    }
1184
1185    #[test]
1186    fn schema_no_duplicate_labels() {
1187        let mut labels: Vec<&str> = Git::schema().iter().map(|f| f.label.as_str()).collect();
1188        labels.sort_unstable();
1189        let before = labels.len();
1190        labels.dedup();
1191        assert_eq!(before, labels.len(), "duplicate labels in GIT schema");
1192    }
1193
1194    /// Each GIT list declares at least the children it declared before.
1195    ///
1196    /// A floor rather than an exact count. The exact form asserted numbers
1197    /// that were only ever a snapshot, so declaring the saved forms' labels
1198    /// broke it without anything being wrong; a floor still catches the case
1199    /// that matters, which is a child table losing entries and taking the
1200    /// coverage check's reach down with it.
1201    ///
1202    /// Summed across the element's parts and both arms of a split, since that
1203    /// is where a list's children live now.
1204    #[test]
1205    fn every_list_declares_at_least_the_children_it_had() {
1206        const FLOOR: &[(&str, usize)] = &[
1207            ("Creature List", 104),
1208            ("List", 29),
1209            ("Door List", 60),
1210            ("Placeable List", 65),
1211            ("WaypointList", 17),
1212            ("SoundList", 27),
1213            ("TriggerList", 37),
1214            ("StoreList", 16),
1215            ("Encounter List", 7),
1216        ];
1217        let count =
1218            |parts: &[&'static [Field]]| -> usize { parts.iter().map(|part| part.len()).sum() };
1219        for (label, floor) in FLOOR {
1220            let entry = Git::schema()
1221                .iter()
1222                .find(|f| f.label.as_str() == *label)
1223                .unwrap_or_else(|| panic!("schema must contain {label}"));
1224            let Shape::List { element, split, .. } = entry.shape else {
1225                panic!("{label} is a list");
1226            };
1227            let children =
1228                count(element) + split.map_or(0, |s| count(s.when_set) + count(s.when_clear));
1229            assert!(
1230                children >= *floor,
1231                "{label} declares {children} children, fewer than the {floor} it had"
1232            );
1233        }
1234    }
1235}