1use std::io::{Cursor, Read, Write};
17
18use crate::shared::ObjectId;
19use rakata_core::ResRef;
20use rakata_formats::gff::{get_string, upsert_field};
21use rakata_formats::gff_label;
22use rakata_formats::schema::FromGff;
23use rakata_formats::GENERIC_FILE_TYPE;
24use rakata_formats::{
25 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
26 GffStruct, GffValue,
27};
28use thiserror::Error;
29
30const SAVED_ROSTER_ELEMENT_ID: i32 = 0xbead;
37
38#[derive(Debug, Clone, PartialEq, GffModel)]
40#[gff_entry("Creature List", container = list, live, not_a_constant)]
41#[gff_entry(SWVarTable, container = nested, not_a_constant)]
42#[gff_entry(VarTable, container = list, not_a_constant)]
43pub struct Ifo {
44 #[gff(Mod_ID, unexamined)]
59 pub module_id: Vec<u8>,
60 #[gff(Mod_Creator_ID, unexamined)]
62 pub creator_id: i32,
63 #[gff(Mod_Version, unexamined)]
65 pub version: u32,
66 #[gff(Mod_Name, unexamined)]
68 pub name: GffLocalizedString,
69 #[gff(Mod_Description, unexamined)]
71 pub description: GffLocalizedString,
72 #[gff(Mod_Tag, stamped)]
74 pub tag: String,
75 #[gff(Mod_IsSaveGame, constructed)]
77 pub is_save_game: bool,
78 #[gff(Mod_IsNWMFile, constructed)]
80 pub is_nwm_file: bool,
81 #[gff(Mod_NWMResName, manual_write, manual_read, constructed)]
83 pub nwm_res_name: String,
84 #[gff(Mod_StartMovie, stamped)]
86 pub start_movie: ResRef,
87 #[gff(Mod_Entry_Area, unexamined)]
89 pub entry_area: ResRef,
90 #[gff(Mod_Entry_X, unexamined)]
92 pub entry_x: f32,
93 #[gff(Mod_Entry_Y, unexamined)]
95 pub entry_y: f32,
96 #[gff(Mod_Entry_Z, unexamined)]
98 pub entry_z: f32,
99 #[gff(Mod_Entry_Dir_X, not_a_constant)]
101 pub entry_dir_x: f32,
102 #[gff(Mod_Entry_Dir_Y, not_a_constant)]
104 pub entry_dir_y: f32,
105 #[gff(Mod_MinPerHour, stamped)]
107 pub min_per_hour: u8,
108 #[gff(Mod_DawnHour, stamped)]
110 pub dawn_hour: u8,
111 #[gff(Mod_DuskHour, stamped)]
113 pub dusk_hour: u8,
114 #[gff(Mod_XPScale, stamped = 10)]
116 pub xp_scale: u8,
117 #[gff(Mod_StartYear, stamped = 1340)]
119 pub start_year: u32,
120 #[gff(Mod_StartMonth, stamped = 6)]
122 pub start_month: u8,
123 #[gff(Mod_StartDay, stamped = 1)]
125 pub start_day: u8,
126 #[gff(Mod_StartHour, stamped = 23)]
128 pub start_hour: u8,
129 #[gff(Mod_Transition, stamped)]
131 pub transition: u32,
132 #[gff(Mod_StartMinute, stamped)]
134 pub start_minute: u16,
135 #[gff(Mod_StartSecond, stamped)]
137 pub start_second: u16,
138 #[gff(Mod_StartMiliSec, stamped)]
140 pub start_millisecond: u16,
141 #[gff(Mod_PauseTime, stamped)]
143 pub pause_time: u32,
144 #[gff(Mod_PauseDay, stamped)]
146 pub pause_day: u32,
147 #[gff(Mod_Effect_NxtId, stamped)]
149 pub effect_next_id: u64,
150 #[gff(Mod_NextCharId0, stamped)]
152 pub next_char_id_0: u32,
153 #[gff(Mod_NextCharId1, stamped)]
155 pub next_char_id_1: u32,
156 #[gff(Mod_NextObjId0, stamped)]
158 pub next_obj_id_0: u32,
159 #[gff(Mod_NextObjId1, stamped)]
161 pub next_obj_id_1: u32,
162 #[gff(Mod_Hak, manual_write, unexamined)]
164 pub hak: String,
165 #[gff(Mod_OnHeartbeat, stamped)]
167 pub on_heartbeat: ResRef,
168 #[gff(Mod_OnUsrDefined, stamped)]
170 pub on_user_defined: ResRef,
171 #[gff(Mod_OnModLoad, stamped)]
173 pub on_mod_load: ResRef,
174 #[gff(Mod_OnModStart, stamped)]
176 pub on_mod_start: ResRef,
177 #[gff(Mod_OnClientEntr, stamped)]
179 pub on_client_enter: ResRef,
180 #[gff(Mod_OnClientLeav, stamped)]
182 pub on_client_leave: ResRef,
183 #[gff(Mod_OnActvtItem, stamped)]
185 pub on_activate_item: ResRef,
186 #[gff(Mod_OnAcquirItem, stamped)]
188 pub on_acquire_item: ResRef,
189 #[gff(Mod_OnUnAqreItem, stamped)]
191 pub on_unacquire_item: ResRef,
192 #[gff(Mod_OnPlrDeath, stamped)]
194 pub on_player_death: ResRef,
195 #[gff(Mod_OnPlrDying, stamped)]
197 pub on_player_dying: ResRef,
198 #[gff(Mod_OnSpawnBtnDn, stamped)]
200 pub on_spawn_btn_down: ResRef,
201 #[gff(Mod_OnPlrRest, stamped)]
203 pub on_player_rest: ResRef,
204 #[gff(Mod_OnPlrLvlUp, stamped)]
206 pub on_player_level_up: ResRef,
207 #[gff(Mod_OnEquipItem, stamped, omit = audited_constant(1057))]
209 pub on_equip_item: ResRef,
210 #[gff(Mod_Expan_List, unexamined, list = IfoExpansion, element_id = 0)]
212 pub expansion_list: Vec<IfoExpansion>,
213 #[gff(Mod_CutSceneList, unanswered, list = IfoCutScene, element_id = 1)]
215 pub cutscene_list: Vec<IfoCutScene>,
216 #[gff(Mod_Area_list, unanswered, list = IfoArea, element_id = 6)]
218 pub areas: Vec<IfoArea>,
219 #[gff(Mod_PlayerList, manual_write, not_a_constant, list = IfoPlayer, element_id = 0xbead)]
224 pub player_list: Vec<IfoPlayer>,
225 #[gff(Mod_Tokens, manual_write, not_a_constant, list = IfoToken, element_id = 7)]
227 pub tokens: Vec<IfoToken>,
228}
229
230impl Ifo {
231 pub fn new() -> Self {
233 Self::default()
234 }
235
236 pub fn from_gff(gff: &Gff) -> Result<Self, IfoError> {
243 if gff.file_type != <Ifo as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
244 return Err(IfoError::UnsupportedFileType(gff.file_type));
245 }
246
247 let root = &gff.root;
248 let mut ifo = Self::read_declared(root);
249
250 ifo.nwm_res_name = if ifo.is_nwm_file {
254 get_string(root, "Mod_NWMResName").unwrap_or_default()
255 } else {
256 String::new()
257 };
258
259 Ok(ifo)
260 }
261
262 fn write_conditional(&self, root: &mut GffStruct) {
269 if self.is_nwm_file {
270 upsert_field(
271 root,
272 gff_label!("Mod_NWMResName"),
273 GffValue::String(self.nwm_res_name.clone()),
274 );
275 }
276 if !self.hak.is_empty() {
277 upsert_field(
278 root,
279 gff_label!("Mod_Hak"),
280 GffValue::String(self.hak.clone()),
281 );
282 }
283 if !self.player_list.is_empty() {
284 upsert_field(
285 root,
286 gff_label!("Mod_PlayerList"),
287 GffValue::List(
288 self.player_list
289 .iter()
290 .map(|player| {
291 let mut element = GffStruct::new(SAVED_ROSTER_ELEMENT_ID);
292 player.write_element(&mut element);
293 element
294 })
295 .collect(),
296 ),
297 );
298 }
299 if !self.tokens.is_empty() {
300 upsert_field(
301 root,
302 gff_label!("Mod_Tokens"),
303 GffValue::List(
304 self.tokens
305 .iter()
306 .map(|token| {
307 let mut element = GffStruct::new(7);
308 token.write_element(&mut element);
309 element
310 })
311 .collect(),
312 ),
313 );
314 }
315 }
316
317 pub fn to_gff(&self) -> Gff {
319 let mut root = GffStruct::new(-1);
320 self.write_declared(&mut root);
321 self.write_conditional(&mut root);
322 Gff::new(*b"IFO ", root)
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
328pub struct IfoArea {
329 #[gff(Area_Name, stamped)]
331 pub area_name: ResRef,
332 #[gff(ObjectId, manual_write, stamped = ObjectId::INVALID)]
334 pub object_id: ObjectId,
335}
336
337impl IfoArea {
338 fn write_element(&self, structure: &mut GffStruct) {
343 self.write_declared(structure);
344 if self.object_id.is_valid() {
347 upsert_field(
348 structure,
349 gff_label!("ObjectId"),
350 GffValue::UInt32(self.object_id.get()),
351 );
352 }
353 }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
358pub struct IfoExpansion {
359 #[gff(Expansion_Name, stamped)]
361 pub expansion_name: GffLocalizedString,
362 #[gff(Expansion_ID, stamped)]
364 pub expansion_id: i32,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
369pub struct IfoCutScene {
370 #[gff(CutScene_Name, stamped)]
372 pub cutscene_name: ResRef,
373 #[gff(CutScene_ID, stamped)]
375 pub cutscene_id: u32,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
380pub struct IfoPlayer {
381 #[gff(Mod_CommntyName, stamped)]
383 pub community_name: String,
384 #[gff(Mod_FirstName, stamped)]
386 pub first_name: GffLocalizedString,
387 #[gff(Mod_LastName, stamped)]
389 pub last_name: GffLocalizedString,
390 #[gff(Mod_IsPrimaryPlr, stamped)]
392 pub is_primary_player: bool,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
397pub struct IfoToken {
398 #[gff(Mod_TokensNumber, stamped)]
400 pub token_number: u32,
401 #[gff(Mod_TokensValue, stamped)]
403 pub token_value: String,
404}
405
406#[derive(Debug, Error)]
408pub enum IfoError {
409 #[error("unsupported IFO file type: {0:?}")]
411 UnsupportedFileType([u8; 4]),
412 #[error(transparent)]
414 Gff(#[from] GffBinaryError),
415}
416
417#[cfg_attr(
425 feature = "tracing",
426 tracing::instrument(level = "debug", skip(reader))
427)]
428pub fn read_ifo<R: Read>(reader: &mut R) -> Result<Ifo, IfoError> {
429 let gff = read_gff(reader)?;
430 Ifo::from_gff(&gff)
431}
432
433#[cfg_attr(
441 feature = "tracing",
442 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
443)]
444pub fn read_ifo_from_bytes(bytes: &[u8]) -> Result<Ifo, IfoError> {
445 let gff = read_gff_from_bytes(bytes)?;
446 Ifo::from_gff(&gff)
447}
448
449#[cfg_attr(
457 feature = "tracing",
458 tracing::instrument(level = "debug", skip(writer, ifo))
459)]
460pub fn author_ifo<W: Write>(writer: &mut W, ifo: &Ifo) -> Result<(), IfoError> {
461 let gff = ifo.to_gff();
462 write_gff(writer, &gff)?;
463 Ok(())
464}
465
466#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(ifo)))]
473pub fn author_ifo_to_vec(ifo: &Ifo) -> Result<Vec<u8>, IfoError> {
474 let mut cursor = Cursor::new(Vec::new());
475 author_ifo(&mut cursor, ifo)?;
476 Ok(cursor.into_inner())
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use rakata_core::StrRef;
483 use rakata_formats::schema::{HasSchema, Shape};
484 use rakata_formats::{gff_label, GffValue};
485
486 fn make_test_ifo_gff() -> Gff {
488 let mut root = GffStruct::new(-1);
489 root.push_field(gff_label!("Mod_IsSaveGame"), GffValue::UInt8(0));
490 root.push_field(gff_label!("Mod_IsNWMFile"), GffValue::UInt8(0));
491 root.push_field(gff_label!("Mod_ID"), GffValue::Binary(vec![0xAB; 32]));
492 root.push_field(gff_label!("Mod_Creator_ID"), GffValue::Int32(42));
493 root.push_field(gff_label!("Mod_Version"), GffValue::UInt32(3));
494 root.push_field(gff_label!("Mod_Tag"), GffValue::String("end_m01aa".into()));
495 root.push_field(
496 gff_label!("Mod_Name"),
497 GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(42000))),
498 );
499 root.push_field(
500 gff_label!("Mod_Description"),
501 GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(42001))),
502 );
503 root.push_field(
504 gff_label!("Mod_StartMovie"),
505 GffValue::resref_lit("leclogo"),
506 );
507 root.push_field(gff_label!("Mod_Entry_Area"), GffValue::resref_lit("m01aa"));
508 root.push_field(gff_label!("Mod_Entry_X"), GffValue::Single(10.5));
509 root.push_field(gff_label!("Mod_Entry_Y"), GffValue::Single(20.3));
510 root.push_field(gff_label!("Mod_Entry_Z"), GffValue::Single(0.0));
511 root.push_field(gff_label!("Mod_Entry_Dir_X"), GffValue::Single(0.0));
512 root.push_field(gff_label!("Mod_Entry_Dir_Y"), GffValue::Single(1.0));
513 root.push_field(gff_label!("Mod_MinPerHour"), GffValue::UInt8(2));
514 root.push_field(gff_label!("Mod_DawnHour"), GffValue::UInt8(6));
515 root.push_field(gff_label!("Mod_DuskHour"), GffValue::UInt8(18));
516 root.push_field(gff_label!("Mod_XPScale"), GffValue::UInt8(10));
517
518 root.push_field(
519 gff_label!("Mod_OnHeartbeat"),
520 GffValue::resref_lit("k_mod_hb"),
521 );
522 root.push_field(gff_label!("Mod_OnUsrDefined"), GffValue::resref_lit(""));
523 root.push_field(
524 gff_label!("Mod_OnModLoad"),
525 GffValue::resref_lit("k_mod_load"),
526 );
527 root.push_field(
528 gff_label!("Mod_OnModStart"),
529 GffValue::resref_lit("k_mod_start"),
530 );
531 root.push_field(
532 gff_label!("Mod_OnClientEntr"),
533 GffValue::resref_lit("k_mod_enter"),
534 );
535 root.push_field(gff_label!("Mod_OnClientLeav"), GffValue::resref_lit(""));
536 root.push_field(
537 gff_label!("Mod_OnActvtItem"),
538 GffValue::resref_lit("k_act_item"),
539 );
540 root.push_field(
541 gff_label!("Mod_OnAcquirItem"),
542 GffValue::resref_lit("k_acq_item"),
543 );
544 root.push_field(gff_label!("Mod_OnUnAqreItem"), GffValue::resref_lit(""));
545 root.push_field(
546 gff_label!("Mod_OnPlrDeath"),
547 GffValue::resref_lit("k_plr_death"),
548 );
549 root.push_field(
550 gff_label!("Mod_OnPlrDying"),
551 GffValue::resref_lit("k_plr_dying"),
552 );
553 root.push_field(gff_label!("Mod_OnSpawnBtnDn"), GffValue::resref_lit(""));
554 root.push_field(
555 gff_label!("Mod_OnPlrRest"),
556 GffValue::resref_lit("k_plr_rest"),
557 );
558 root.push_field(gff_label!("Mod_OnPlrLvlUp"), GffValue::resref_lit(""));
559 root.push_field(gff_label!("Mod_OnEquipItem"), GffValue::resref_lit(""));
560
561 let mut a1 = GffStruct::new(6);
563 a1.push_field(gff_label!("Area_Name"), GffValue::resref_lit("m01aa"));
564 let mut a2 = GffStruct::new(6);
565 a2.push_field(gff_label!("Area_Name"), GffValue::resref_lit("m01ab"));
566 root.push_field(gff_label!("Mod_Area_list"), GffValue::List(vec![a1, a2]));
567
568 root.push_field(gff_label!("Mod_Expan_List"), GffValue::List(Vec::new()));
570 root.push_field(gff_label!("Mod_CutSceneList"), GffValue::List(Vec::new()));
571
572 Gff::new(*b"IFO ", root)
573 }
574
575 fn make_save_game_ifo_gff() -> Gff {
577 let mut gff = make_test_ifo_gff();
578 for field in &mut gff.root.fields {
580 if field.label == "Mod_IsSaveGame" {
581 field.value = GffValue::UInt8(1);
582 }
583 }
584
585 gff.root
587 .push_field(gff_label!("Mod_StartYear"), GffValue::UInt32(1340));
588 gff.root
589 .push_field(gff_label!("Mod_StartMonth"), GffValue::UInt8(6));
590 gff.root
591 .push_field(gff_label!("Mod_StartDay"), GffValue::UInt8(1));
592 gff.root
593 .push_field(gff_label!("Mod_StartHour"), GffValue::UInt8(23));
594 gff.root
595 .push_field(gff_label!("Mod_StartMinute"), GffValue::UInt16(30));
596 gff.root
597 .push_field(gff_label!("Mod_StartSecond"), GffValue::UInt16(15));
598 gff.root
599 .push_field(gff_label!("Mod_StartMiliSec"), GffValue::UInt16(500));
600 gff.root
601 .push_field(gff_label!("Mod_Transition"), GffValue::UInt32(1));
602 gff.root
603 .push_field(gff_label!("Mod_PauseTime"), GffValue::UInt32(1000));
604 gff.root
605 .push_field(gff_label!("Mod_PauseDay"), GffValue::UInt32(5));
606
607 gff.root
609 .push_field(gff_label!("Mod_Effect_NxtId"), GffValue::UInt64(999));
610 gff.root
611 .push_field(gff_label!("Mod_NextCharId0"), GffValue::UInt32(10));
612 gff.root
613 .push_field(gff_label!("Mod_NextCharId1"), GffValue::UInt32(20));
614 gff.root
615 .push_field(gff_label!("Mod_NextObjId0"), GffValue::UInt32(100));
616 gff.root
617 .push_field(gff_label!("Mod_NextObjId1"), GffValue::UInt32(200));
618
619 gff.root
621 .push_field(gff_label!("Mod_Hak"), GffValue::String("my_hak".into()));
622
623 gff.root.fields.retain(|f| f.label != "Mod_Area_list");
625 let mut a1 = GffStruct::new(6);
626 a1.push_field(gff_label!("Area_Name"), GffValue::resref_lit("m01aa"));
627 a1.push_field(gff_label!("ObjectId"), GffValue::UInt32(0x7F00_0001));
628 gff.root
629 .push_field(gff_label!("Mod_Area_list"), GffValue::List(vec![a1]));
630
631 let mut player = GffStruct::new(0);
633 player.push_field(
634 gff_label!("Mod_CommntyName"),
635 GffValue::String("TestPlayer".into()),
636 );
637 use rakata_formats::GffLocalizedSubstring;
638 let first = GffLocalizedString {
639 string_ref: StrRef::invalid(),
640 substrings: vec![GffLocalizedSubstring {
641 string_id: 0,
642 text: "Revan".into(),
643 }],
644 };
645 player.push_field(
646 gff_label!("Mod_FirstName"),
647 GffValue::LocalizedString(first),
648 );
649 player.push_field(
650 gff_label!("Mod_LastName"),
651 GffValue::LocalizedString(GffLocalizedString::new(StrRef::invalid())),
652 );
653 player.push_field(gff_label!("Mod_IsPrimaryPlr"), GffValue::UInt8(1));
654 gff.root
655 .push_field(gff_label!("Mod_PlayerList"), GffValue::List(vec![player]));
656
657 let mut token = GffStruct::new(7);
659 token.push_field(gff_label!("Mod_TokensNumber"), GffValue::UInt32(0));
660 token.push_field(
661 gff_label!("Mod_TokensValue"),
662 GffValue::String("Revan".into()),
663 );
664 gff.root
665 .push_field(gff_label!("Mod_Tokens"), GffValue::List(vec![token]));
666
667 gff.root.fields.retain(|f| f.label != "Mod_Expan_List");
669 let mut exp = GffStruct::new(0);
670 exp.push_field(
671 gff_label!("Expansion_Name"),
672 GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(100))),
673 );
674 exp.push_field(gff_label!("Expansion_ID"), GffValue::Int32(1));
675 gff.root
676 .push_field(gff_label!("Mod_Expan_List"), GffValue::List(vec![exp]));
677
678 gff.root.fields.retain(|f| f.label != "Mod_CutSceneList");
680 let mut cs = GffStruct::new(1);
681 cs.push_field(
682 gff_label!("CutScene_Name"),
683 GffValue::resref_lit("cs_intro"),
684 );
685 cs.push_field(gff_label!("CutScene_ID"), GffValue::UInt32(0));
686 gff.root
687 .push_field(gff_label!("Mod_CutSceneList"), GffValue::List(vec![cs]));
688
689 gff
690 }
691
692 #[test]
693 fn reads_core_ifo_fields() {
694 let gff = make_test_ifo_gff();
695 let ifo = Ifo::from_gff(&gff).expect("must parse");
696
697 assert_eq!(ifo.tag, "end_m01aa");
698 assert_eq!(ifo.name.string_ref.raw(), 42000);
699 assert_eq!(ifo.description.string_ref.raw(), 42001);
700 assert_eq!(ifo.start_movie, "leclogo");
701 assert_eq!(ifo.entry_area, "m01aa");
702 assert_eq!(ifo.entry_x, 10.5);
703 assert_eq!(ifo.entry_y, 20.3);
704 assert_eq!(ifo.entry_z, 0.0);
705 assert_eq!(ifo.entry_dir_x, 0.0);
706 assert_eq!(ifo.entry_dir_y, 1.0);
707 assert_eq!(ifo.min_per_hour, 2);
708 assert_eq!(ifo.dawn_hour, 6);
709 assert_eq!(ifo.dusk_hour, 18);
710 assert_eq!(ifo.xp_scale, 10);
711 }
712
713 #[test]
714 fn reads_root_identity_fields() {
715 let gff = make_test_ifo_gff();
716 let ifo = Ifo::from_gff(&gff).expect("must parse");
717
718 assert!(!ifo.is_save_game);
719 assert!(!ifo.is_nwm_file);
720 assert!(ifo.nwm_res_name.is_empty());
721 assert_eq!(ifo.module_id, vec![0xAB; 32]);
722 assert_eq!(ifo.creator_id, 42);
723 assert_eq!(ifo.version, 3);
724 }
725
726 #[test]
727 fn a_module_id_of_any_length_survives_a_round_trip() {
728 for length in [0, 1, 15, 16, 17, 31, 32, 33, 64] {
732 let mut root = GffStruct::new(-1);
733 root.push_field(gff_label!("Mod_ID"), GffValue::Binary(vec![0xCD; length]));
734 let ifo = Ifo::from_gff(&Gff::new(*b"IFO ", root)).expect("must parse");
735
736 assert_eq!(
737 ifo.module_id,
738 vec![0xCD; length],
739 "a {length}-byte Mod_ID must survive the read"
740 );
741 assert_eq!(
742 Ifo::from_gff(&ifo.to_gff()).expect("must parse").module_id,
743 vec![0xCD; length],
744 "a {length}-byte Mod_ID must survive the write"
745 );
746 }
747 }
748
749 #[test]
750 fn reads_scripts() {
751 let gff = make_test_ifo_gff();
752 let ifo = Ifo::from_gff(&gff).expect("must parse");
753
754 assert_eq!(ifo.on_heartbeat, "k_mod_hb");
755 assert_eq!(ifo.on_user_defined, "");
756 assert_eq!(ifo.on_mod_load, "k_mod_load");
757 assert_eq!(ifo.on_mod_start, "k_mod_start");
758 assert_eq!(ifo.on_client_enter, "k_mod_enter");
759 assert_eq!(ifo.on_client_leave, "");
760 assert_eq!(ifo.on_activate_item, "k_act_item");
761 assert_eq!(ifo.on_acquire_item, "k_acq_item");
762 assert_eq!(ifo.on_unacquire_item, "");
763 assert_eq!(ifo.on_player_death, "k_plr_death");
764 assert_eq!(ifo.on_player_dying, "k_plr_dying");
765 assert_eq!(ifo.on_spawn_btn_down, "");
766 assert_eq!(ifo.on_player_rest, "k_plr_rest");
767 assert_eq!(ifo.on_player_level_up, "");
768 assert_eq!(ifo.on_equip_item, "");
769 }
770
771 #[test]
772 fn reads_area_list() {
773 let gff = make_test_ifo_gff();
774 let ifo = Ifo::from_gff(&gff).expect("must parse");
775
776 assert_eq!(ifo.areas.len(), 2);
777 assert_eq!(ifo.areas[0].area_name, "m01aa");
778 assert_eq!(ifo.areas[0].object_id, ObjectId::INVALID);
783 assert_eq!(ifo.areas[1].area_name, "m01ab");
784 assert_eq!(ifo.areas[1].object_id, ObjectId::INVALID);
785 }
786
787 #[test]
788 fn reads_save_game_fields() {
789 let gff = make_save_game_ifo_gff();
790 let ifo = Ifo::from_gff(&gff).expect("must parse");
791
792 assert!(ifo.is_save_game);
793 assert_eq!(ifo.start_year, 1340);
794 assert_eq!(ifo.start_month, 6);
795 assert_eq!(ifo.start_day, 1);
796 assert_eq!(ifo.start_hour, 23);
797 assert_eq!(ifo.start_minute, 30);
798 assert_eq!(ifo.start_second, 15);
799 assert_eq!(ifo.start_millisecond, 500);
800 assert_eq!(ifo.transition, 1);
801 assert_eq!(ifo.pause_time, 1000);
802 assert_eq!(ifo.pause_day, 5);
803 assert_eq!(ifo.effect_next_id, 999);
804 assert_eq!(ifo.next_char_id_0, 10);
805 assert_eq!(ifo.next_char_id_1, 20);
806 assert_eq!(ifo.next_obj_id_0, 100);
807 assert_eq!(ifo.next_obj_id_1, 200);
808 assert_eq!(ifo.hak, "my_hak");
809 }
810
811 #[test]
812 fn reads_area_object_id() {
813 let gff = make_save_game_ifo_gff();
814 let ifo = Ifo::from_gff(&gff).expect("must parse");
815
816 assert_eq!(ifo.areas.len(), 1);
817 assert_eq!(ifo.areas[0].area_name, "m01aa");
818 assert_eq!(ifo.areas[0].object_id, ObjectId::new(0x7F00_0001));
819 }
820
821 #[test]
822 fn reads_player_list() {
823 let gff = make_save_game_ifo_gff();
824 let ifo = Ifo::from_gff(&gff).expect("must parse");
825
826 assert_eq!(ifo.player_list.len(), 1);
827 assert_eq!(ifo.player_list[0].community_name, "TestPlayer");
828 assert!(ifo.player_list[0].is_primary_player);
829 assert_eq!(ifo.player_list[0].first_name.substrings[0].text, "Revan");
830 }
831
832 #[test]
833 fn reads_tokens() {
834 let gff = make_save_game_ifo_gff();
835 let ifo = Ifo::from_gff(&gff).expect("must parse");
836
837 assert_eq!(ifo.tokens.len(), 1);
838 assert_eq!(ifo.tokens[0].token_number, 0);
839 assert_eq!(ifo.tokens[0].token_value, "Revan");
840 }
841
842 #[test]
843 fn reads_expansion_list() {
844 let gff = make_save_game_ifo_gff();
845 let ifo = Ifo::from_gff(&gff).expect("must parse");
846
847 assert_eq!(ifo.expansion_list.len(), 1);
848 assert_eq!(ifo.expansion_list[0].expansion_name.string_ref.raw(), 100);
849 assert_eq!(ifo.expansion_list[0].expansion_id, 1);
850 }
851
852 #[test]
853 fn reads_cutscene_list() {
854 let gff = make_save_game_ifo_gff();
855 let ifo = Ifo::from_gff(&gff).expect("must parse");
856
857 assert_eq!(ifo.cutscene_list.len(), 1);
858 assert_eq!(ifo.cutscene_list[0].cutscene_name, "cs_intro");
859 assert_eq!(ifo.cutscene_list[0].cutscene_id, 0);
860 }
861
862 #[test]
863 fn all_fields_survive_typed_roundtrip() {
864 let gff = make_save_game_ifo_gff();
865 let ifo = Ifo::from_gff(&gff).expect("typed parse");
866 let bytes = author_ifo_to_vec(&ifo).expect("write succeeds");
867 let reparsed = read_ifo_from_bytes(&bytes).expect("reparse succeeds");
868
869 assert_eq!(ifo, reparsed);
870 }
871
872 #[test]
873 fn typed_edits_roundtrip_through_gff_writer() {
874 let gff = make_test_ifo_gff();
875 let mut ifo = Ifo::from_gff(&gff).expect("must parse");
876 ifo.tag = "end_m01ab".into();
877 ifo.entry_area = ResRef::new("m01ab").expect("valid test resref");
878 ifo.entry_x = 50.0;
879 ifo.areas.push(IfoArea {
880 area_name: ResRef::new("m01ac").expect("valid test resref"),
881 object_id: ObjectId::new(0),
882 });
883
884 let bytes = author_ifo_to_vec(&ifo).expect("write succeeds");
885 let reparsed = read_ifo_from_bytes(&bytes).expect("reparse succeeds");
886
887 assert_eq!(reparsed.tag, "end_m01ab");
888 assert_eq!(reparsed.entry_area, "m01ab");
889 assert_eq!(reparsed.entry_x, 50.0);
890 assert_eq!(reparsed.areas.len(), 3);
891 assert_eq!(reparsed.areas[2].area_name, "m01ac");
892 }
893
894 #[test]
895 fn read_ifo_from_reader_matches_bytes_path() {
896 let gff = make_test_ifo_gff();
897 let bytes = {
898 let mut c = Cursor::new(Vec::new());
899 write_gff(&mut c, &gff).expect("test fixture must be valid");
900 c.into_inner()
901 };
902
903 let mut cursor = Cursor::new(&bytes);
904 let via_reader = read_ifo(&mut cursor).expect("reader parse succeeds");
905 let via_bytes = read_ifo_from_bytes(&bytes).expect("bytes parse succeeds");
906
907 assert_eq!(via_reader, via_bytes);
908 }
909
910 #[test]
911 fn rejects_non_ifo_file_type() {
912 let mut gff = make_test_ifo_gff();
913 gff.file_type = *b"UTT ";
914
915 let err = Ifo::from_gff(&gff).expect_err("UTT must be rejected as IFO input");
916 assert!(matches!(
917 err,
918 IfoError::UnsupportedFileType(file_type) if file_type == *b"UTT "
919 ));
920 }
921
922 #[test]
923 fn write_ifo_matches_direct_gff_writer() {
924 let gff = make_test_ifo_gff();
925 let ifo = Ifo::from_gff(&gff).expect("must parse");
926
927 let via_typed = author_ifo_to_vec(&ifo).expect("typed write succeeds");
928
929 let mut direct = Cursor::new(Vec::new());
930 write_gff(&mut direct, &ifo.to_gff()).expect("direct write succeeds");
931
932 assert_eq!(via_typed, direct.into_inner());
933 }
934
935 #[test]
936 fn empty_area_list_ok() {
937 let mut gff = make_test_ifo_gff();
938 gff.root.fields.retain(|f| f.label != "Mod_Area_list");
939
940 let ifo = Ifo::from_gff(&gff).expect("must parse");
941 assert!(ifo.areas.is_empty());
942 }
943
944 #[test]
945 fn schema_field_count() {
946 assert_eq!(Ifo::schema().len(), 59);
947 }
948
949 #[test]
950 fn schema_no_duplicate_labels() {
951 let mut labels: Vec<&str> = Ifo::schema().iter().map(|f| f.label.as_str()).collect();
952 labels.sort_unstable();
953 let before = labels.len();
954 labels.dedup();
955 assert_eq!(before, labels.len(), "duplicate labels in IFO schema");
956 }
957
958 #[test]
959 fn schema_lists_carry_their_elements() {
960 let element_count = |label: &str| {
961 let field = Ifo::schema()
962 .iter()
963 .find(|f| f.label.as_str() == label)
964 .expect("declared");
965 match field.shape {
966 Shape::List { element, .. } => element.iter().map(|p| p.len()).sum::<usize>(),
969 other => panic!("{label} is a list, got {other:?}"),
970 }
971 };
972 assert_eq!(element_count("Mod_Expan_List"), 2);
973 assert_eq!(element_count("Mod_CutSceneList"), 2);
974 assert_eq!(element_count("Mod_Area_list"), 2);
975 assert_eq!(element_count("Mod_PlayerList"), 4);
976 assert_eq!(element_count("Mod_Tokens"), 2);
977
978 assert_eq!(element_count("Creature List"), 0);
981 assert_eq!(element_count("VarTable"), 0);
982 let Shape::Struct { fields } = Ifo::schema()
983 .iter()
984 .find(|f| f.label.as_str() == "SWVarTable")
985 .expect("declared")
986 .shape
987 else {
988 panic!("SWVarTable is a struct");
989 };
990 assert!(fields.is_empty());
991 }
992}