1use 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#[derive(Debug, Clone, PartialEq)]
90pub enum GitObjects<S, T> {
91 Static(Vec<S>),
93 Saved(Vec<T>),
95}
96
97impl<S, T> Default for GitObjects<S, T> {
98 fn default() -> Self {
101 Self::Saved(Vec::new())
102 }
103}
104
105impl<S, T> GitObjects<S, T> {
106 pub fn uses_templates(&self) -> bool {
109 matches!(self, Self::Static(_))
110 }
111
112 pub fn len(&self) -> usize {
114 match self {
115 Self::Static(entries) => entries.len(),
116 Self::Saved(entries) => entries.len(),
117 }
118 }
119
120 pub fn is_empty(&self) -> bool {
122 self.len() == 0
123 }
124
125 pub fn as_static(&self) -> Option<&[S]> {
127 match self {
128 Self::Static(entries) => Some(entries),
129 Self::Saved(_) => None,
130 }
131 }
132
133 pub fn as_saved(&self) -> Option<&[T]> {
135 match self {
136 Self::Saved(entries) => Some(entries),
137 Self::Static(_) => None,
138 }
139 }
140
141 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 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
158pub type GitCreatures = GitObjects<GitCreature, SavedCreature>;
160
161pub type GitDoors = GitObjects<GitDoor, SavedDoor>;
163
164pub type GitPlaceables = GitObjects<GitPlaceable, SavedPlaceable>;
166
167pub type GitItems = GitObjects<GitItem, SavedItem>;
169
170pub type GitSounds = GitObjects<GitSound, SavedSound>;
172
173pub type GitStores = GitObjects<GitStore, SavedStore>;
175
176pub type GitTriggers = GitObjects<GitTrigger, SavedTrigger>;
178
179type AreaMapBytes = Vec<u8>;
189
190#[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#[derive(Debug, Clone, PartialEq, GffModel)]
209#[gff_entry(UseTemplates, wire = u8, stamped)]
210#[gff_entry(VarTable, container = list, unexamined)]
211pub struct Git {
212 #[gff(AreaMap, unexamined, nested = GitAreaMap, optional)]
218 pub area_map: Option<GitAreaMap>,
219 #[gff(CurrentWeather, unexamined)]
221 pub current_weather: u8,
222 #[gff(WeatherStarted, unexamined)]
224 pub weather_started: bool,
225 #[gff(WaypointList, not_a_constant, list = GitWaypoint, element_id = 5)]
227 pub waypoints: Vec<GitWaypoint>,
228 #[gff("Encounter List", not_a_constant, list = GitEncounter, element_id = 7)]
240 pub encounters: Vec<GitEncounter>,
241 #[gff(AreaEffectList, not_a_constant, list = GitAreaEffect, element_id = 13)]
243 pub area_effects: Vec<GitAreaEffect>,
244 #[gff(AreaProperties, unexamined, nested = GitAreaProperties, optional)]
246 pub area_properties: Option<GitAreaProperties>,
247 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 pub fn new() -> Self {
344 Self::default()
345 }
346
347 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 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 pub fn to_gff(&self) -> Gff {
381 let mut root = GffStruct::new(-1);
382 self.write_declared(&mut root);
383
384 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
404fn 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
427fn 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
440pub trait GitElement: Sized {
446 fn read(structure: &GffStruct) -> Self;
448 fn write(&self, id: i32) -> GffStruct;
450}
451
452#[derive(Debug, Clone, PartialEq, GffModel)]
464pub struct GitObjectPlacement {
465 #[gff(ObjectId, stamped = ObjectId::INVALID, optional = ObjectId)]
478 pub object_id: Option<ObjectId>,
479 #[gff(XPosition, unexamined)]
481 pub x_position: f32,
482 #[gff(YPosition, unexamined)]
484 pub y_position: f32,
485 #[gff(ZPosition, unexamined)]
487 pub z_position: f32,
488 #[gff(XOrientation, unexamined)]
490 pub x_orientation: f32,
491 #[gff(YOrientation, unexamined)]
493 pub y_orientation: f32,
494 #[gff(ZOrientation, unexamined, optional = f32)]
501 pub z_orientation: Option<f32>,
502}
503
504macro_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#[derive(Debug, Error)]
540pub enum GitError {
541 #[error("unsupported GIT file type: {0:?}")]
543 UnsupportedFileType([u8; 4]),
544 #[error(transparent)]
546 Gff(#[from] GffBinaryError),
547}
548
549#[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#[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#[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#[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#[cfg(test)]
616mod tests {
617 use super::*;
618 use rakata_formats::schema::{Absent, Field, GffScalar, HasSchema, Resolution, Shape};
619
620 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 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 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 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 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 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 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 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 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 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 let empty = GffStruct::new(0);
904
905 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 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 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 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 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 #[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 #[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}