rakata_generics/
ifo.rs

1//! IFO (`.ifo`) typed generic wrapper.
2//!
3//! IFO resources are GFF-backed module info files that define a module's
4//! identity, entry point, time settings, scripts, and area list.
5//!
6//! ## Scope
7//! - Typed access for all root fields from the Ghidra audit.
8//! - Save-game-only fields (calendar state, ID counters, player list, tokens).
9//! - Typed area list (`Mod_Area_list`) with per-entry area name and object ID.
10//! - Typed script hooks (15 module-level event scripts).
11//! - Typed expansion, cutscene, player, and token lists.
12//!
13//! ## Field Layout (simplified)
14//! ```text
15//! IFO root struct
16//! +-- Mod_IsSaveGame / Mod_IsNWMFile / Mod_NWMResName
17//! +-- Mod_ID / Mod_Creator_ID / Mod_Version
18//! +-- Mod_Tag / Mod_Name / Mod_Description
19//! +-- Mod_Entry_Area / Mod_Entry_X / Mod_Entry_Y / Mod_Entry_Z
20//! +-- Mod_Entry_Dir_X / Mod_Entry_Dir_Y / Mod_StartMovie
21//! +-- Mod_MinPerHour / Mod_DawnHour / Mod_DuskHour / Mod_XPScale
22//! +-- Mod_On* (15 script hooks)
23//! +-- Mod_Area_list[]
24//! |   +-- Area_Name / ObjectId (save-only)
25//! +-- Mod_Expan_List[] / Mod_CutSceneList[]
26//! +-- Mod_PlayerList[] (save-only) / Mod_Tokens[] (save-only)
27//! +-- Save-game calendar state / ID counters / Mod_Hak
28//! ```
29
30use std::io::{Cursor, Read, Write};
31
32use crate::gff_helpers::{
33    get_bool, get_f32, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32, get_u64,
34    get_u8, upsert_field,
35};
36use rakata_core::{ResRef, StrRef};
37use rakata_formats::{
38    gff_schema::{FieldSchema, GffSchema, GffType},
39    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
40    GffValue,
41};
42use thiserror::Error;
43
44/// Typed IFO area list entry (`Mod_Area_list`).
45#[derive(Debug, Clone, PartialEq)]
46pub struct IfoArea {
47    /// Area resref (`Area_Name`).
48    pub area_name: ResRef,
49    /// Object ID assigned at runtime in save games (`ObjectId`).
50    pub object_id: u32,
51}
52
53/// Typed IFO expansion list entry (`Mod_Expan_List`).
54#[derive(Debug, Clone, PartialEq)]
55pub struct IfoExpansion {
56    /// Localized expansion name (`Expansion_Name`).
57    pub expansion_name: GffLocalizedString,
58    /// Expansion ID (`Expansion_ID`).
59    pub expansion_id: i32,
60}
61
62/// Typed IFO cutscene list entry (`Mod_CutSceneList`).
63#[derive(Debug, Clone, PartialEq)]
64pub struct IfoCutScene {
65    /// Cutscene resref (`CutScene_Name`).
66    pub cutscene_name: ResRef,
67    /// Cutscene ID (`CutScene_ID`).
68    pub cutscene_id: u32,
69}
70
71/// Typed IFO player list entry (`Mod_PlayerList`, save-only).
72#[derive(Debug, Clone, PartialEq)]
73pub struct IfoPlayer {
74    /// Community name (`Mod_CommntyName`).
75    pub community_name: String,
76    /// Localized first name (`Mod_FirstName`).
77    pub first_name: GffLocalizedString,
78    /// Localized last name (`Mod_LastName`).
79    pub last_name: GffLocalizedString,
80    /// Whether this is the primary player (`Mod_IsPrimaryPlr`).
81    pub is_primary_player: bool,
82}
83
84/// Typed IFO token entry (`Mod_Tokens`).
85#[derive(Debug, Clone, PartialEq)]
86pub struct IfoToken {
87    /// Token number (`Mod_TokensNumber`).
88    pub token_number: u32,
89    /// Token value (`Mod_TokensValue`).
90    pub token_value: String,
91}
92
93/// Typed IFO model built from/to [`Gff`] data.
94#[derive(Debug, Clone, PartialEq)]
95pub struct Ifo {
96    // --- Root flags ---
97    /// Whether this is a save game (`Mod_IsSaveGame`).
98    pub is_save_game: bool,
99    /// Whether this is an NWM file (`Mod_IsNWMFile`).
100    pub is_nwm_file: bool,
101    /// NWM resource name, only meaningful when `is_nwm_file` is true (`Mod_NWMResName`).
102    pub nwm_res_name: String,
103
104    // --- Module identity ---
105    /// 32-byte module ID blob (`Mod_ID`).
106    pub module_id: [u8; 32],
107    /// Creator ID (`Mod_Creator_ID`).
108    pub creator_id: i32,
109    /// Module version (`Mod_Version`).
110    pub version: u32,
111    /// Module tag (`Mod_Tag`).
112    pub tag: String,
113    /// Localized module name (`Mod_Name`).
114    pub name: GffLocalizedString,
115    /// Localized module description (`Mod_Description`).
116    pub description: GffLocalizedString,
117
118    // --- Entry point ---
119    /// Introductory movie (`Mod_StartMovie`).
120    pub start_movie: ResRef,
121    /// Entry area resref (`Mod_Entry_Area`).
122    pub entry_area: ResRef,
123    /// Entry X coordinate (`Mod_Entry_X`).
124    pub entry_x: f32,
125    /// Entry Y coordinate (`Mod_Entry_Y`).
126    pub entry_y: f32,
127    /// Entry Z coordinate (`Mod_Entry_Z`).
128    pub entry_z: f32,
129    /// Entry facing direction X (`Mod_Entry_Dir_X`).
130    pub entry_dir_x: f32,
131    /// Entry facing direction Y (`Mod_Entry_Dir_Y`).
132    pub entry_dir_y: f32,
133
134    // --- Time / day-night ---
135    /// Minutes per game hour (`Mod_MinPerHour`).
136    pub min_per_hour: u8,
137    /// Dawn hour (`Mod_DawnHour`).
138    pub dawn_hour: u8,
139    /// Dusk hour (`Mod_DuskHour`).
140    pub dusk_hour: u8,
141    /// XP scale multiplier (`Mod_XPScale`).
142    pub xp_scale: u8,
143
144    // --- Save-game-only: calendar state ---
145    /// Game calendar year (`Mod_StartYear`, save-only, default 1340).
146    pub start_year: u32,
147    /// Calendar month (`Mod_StartMonth`, save-only, default 6).
148    pub start_month: u8,
149    /// Calendar day (`Mod_StartDay`, save-only, default 1).
150    pub start_day: u8,
151    /// Calendar hour (`Mod_StartHour`, save-only, default 23).
152    pub start_hour: u8,
153    /// Calendar minute (`Mod_StartMinute`, save-only, default 0).
154    pub start_minute: u16,
155    /// Calendar second (`Mod_StartSecond`, save-only, default 0).
156    pub start_second: u16,
157    /// Calendar millisecond (`Mod_StartMiliSec`, save-only, default 0).
158    pub start_millisecond: u16,
159    /// Transition state (`Mod_Transition`, save-only, default 0).
160    pub transition: u32,
161    /// Paused time-of-day in milliseconds (`Mod_PauseTime`, save-only, default 0).
162    pub pause_time: u32,
163    /// Paused calendar day (`Mod_PauseDay`, save-only, default 0).
164    pub pause_day: u32,
165
166    // --- Save-game-only: ID counters ---
167    /// Next effect ID counter (`Mod_Effect_NxtId`, save-only).
168    pub effect_next_id: u64,
169    /// Next character ID low word (`Mod_NextCharId0`, save-only).
170    pub next_char_id_0: u32,
171    /// Next character ID high word (`Mod_NextCharId1`, save-only).
172    pub next_char_id_1: u32,
173    /// Next object ID low word (`Mod_NextObjId0`, save-only).
174    pub next_obj_id_0: u32,
175    /// Next object ID high word (`Mod_NextObjId1`, save-only).
176    pub next_obj_id_1: u32,
177
178    // --- Save-game-only: Mod_Hak (written but not read by engine) ---
179    /// Hak pack name (`Mod_Hak`, save-only).
180    pub hak: String,
181
182    // --- Scripts (15) ---
183    /// On-heartbeat script (`Mod_OnHeartbeat`).
184    pub on_heartbeat: ResRef,
185    /// On-user-defined script (`Mod_OnUsrDefined`).
186    pub on_user_defined: ResRef,
187    /// On-module-load script (`Mod_OnModLoad`).
188    pub on_mod_load: ResRef,
189    /// On-module-start script (`Mod_OnModStart`).
190    pub on_mod_start: ResRef,
191    /// On-client-enter script (`Mod_OnClientEntr`).
192    pub on_client_enter: ResRef,
193    /// On-client-leave script (`Mod_OnClientLeav`).
194    pub on_client_leave: ResRef,
195    /// On-activate-item script (`Mod_OnActvtItem`).
196    pub on_activate_item: ResRef,
197    /// On-acquire-item script (`Mod_OnAcquirItem`).
198    pub on_acquire_item: ResRef,
199    /// On-unacquire-item script (`Mod_OnUnAqreItem`).
200    pub on_unacquire_item: ResRef,
201    /// On-player-death script (`Mod_OnPlrDeath`).
202    pub on_player_death: ResRef,
203    /// On-player-dying script (`Mod_OnPlrDying`).
204    pub on_player_dying: ResRef,
205    /// On-spawn-button-down script (`Mod_OnSpawnBtnDn`).
206    pub on_spawn_btn_down: ResRef,
207    /// On-player-rest script (`Mod_OnPlrRest`).
208    pub on_player_rest: ResRef,
209    /// On-player-level-up script (`Mod_OnPlrLvlUp`).
210    pub on_player_level_up: ResRef,
211    /// On-equip-item script (`Mod_OnEquipItem`).
212    pub on_equip_item: ResRef,
213
214    // --- Lists ---
215    /// Module areas (`Mod_Area_list`).
216    pub areas: Vec<IfoArea>,
217    /// Expansion list (`Mod_Expan_List`).
218    pub expansion_list: Vec<IfoExpansion>,
219    /// Cutscene list (`Mod_CutSceneList`).
220    pub cutscene_list: Vec<IfoCutScene>,
221    /// Player list (`Mod_PlayerList`, save-only).
222    pub player_list: Vec<IfoPlayer>,
223    /// Token list (`Mod_Tokens`).
224    pub tokens: Vec<IfoToken>,
225}
226
227impl Default for Ifo {
228    fn default() -> Self {
229        Self {
230            is_save_game: false,
231            is_nwm_file: false,
232            nwm_res_name: String::new(),
233            module_id: [0u8; 32],
234            creator_id: 0,
235            version: 0,
236            tag: String::new(),
237            name: GffLocalizedString::new(StrRef::invalid()),
238            description: GffLocalizedString::new(StrRef::invalid()),
239            start_movie: ResRef::blank(),
240            entry_area: ResRef::blank(),
241            entry_x: 0.0,
242            entry_y: 0.0,
243            entry_z: 0.0,
244            entry_dir_x: 0.0,
245            entry_dir_y: 0.0,
246            min_per_hour: 0,
247            dawn_hour: 0,
248            dusk_hour: 0,
249            xp_scale: 10,
250            start_year: 1340,
251            start_month: 6,
252            start_day: 1,
253            start_hour: 23,
254            start_minute: 0,
255            start_second: 0,
256            start_millisecond: 0,
257            transition: 0,
258            pause_time: 0,
259            pause_day: 0,
260            effect_next_id: 0,
261            next_char_id_0: 0,
262            next_char_id_1: 0,
263            next_obj_id_0: 0,
264            next_obj_id_1: 0,
265            hak: String::new(),
266            on_heartbeat: ResRef::blank(),
267            on_user_defined: ResRef::blank(),
268            on_mod_load: ResRef::blank(),
269            on_mod_start: ResRef::blank(),
270            on_client_enter: ResRef::blank(),
271            on_client_leave: ResRef::blank(),
272            on_activate_item: ResRef::blank(),
273            on_acquire_item: ResRef::blank(),
274            on_unacquire_item: ResRef::blank(),
275            on_player_death: ResRef::blank(),
276            on_player_dying: ResRef::blank(),
277            on_spawn_btn_down: ResRef::blank(),
278            on_player_rest: ResRef::blank(),
279            on_player_level_up: ResRef::blank(),
280            on_equip_item: ResRef::blank(),
281            areas: Vec::new(),
282            expansion_list: Vec::new(),
283            cutscene_list: Vec::new(),
284            player_list: Vec::new(),
285            tokens: Vec::new(),
286        }
287    }
288}
289
290impl Ifo {
291    /// Creates an empty IFO value.
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Builds typed IFO data from a parsed GFF container.
297    pub fn from_gff(gff: &Gff) -> Result<Self, IfoError> {
298        if gff.file_type != *b"IFO " && gff.file_type != *b"GFF " {
299            return Err(IfoError::UnsupportedFileType(gff.file_type));
300        }
301
302        let root = &gff.root;
303
304        let is_save_game = get_bool(root, "Mod_IsSaveGame").unwrap_or(false);
305        let is_nwm_file = get_bool(root, "Mod_IsNWMFile").unwrap_or(false);
306        let nwm_res_name = if is_nwm_file {
307            get_string(root, "Mod_NWMResName").unwrap_or_default()
308        } else {
309            String::new()
310        };
311
312        let module_id = match root.field("Mod_ID") {
313            Some(GffValue::Binary(data)) if data.len() == 32 => {
314                <[u8; 32]>::try_from(data.as_slice()).expect("length verified above")
315            }
316            _ => [0u8; 32],
317        };
318
319        let areas = match root.field("Mod_Area_list") {
320            Some(GffValue::List(elements)) => elements
321                .iter()
322                .map(|s| IfoArea {
323                    area_name: get_resref(s, "Area_Name").unwrap_or_default(),
324                    object_id: get_u32(s, "ObjectId").unwrap_or(0),
325                })
326                .collect(),
327            _ => Vec::new(),
328        };
329
330        let expansion_list = match root.field("Mod_Expan_List") {
331            Some(GffValue::List(elements)) => elements
332                .iter()
333                .map(|s| IfoExpansion {
334                    expansion_name: get_locstring(s, "Expansion_Name")
335                        .cloned()
336                        .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
337                    expansion_id: get_i32(s, "Expansion_ID").unwrap_or(0),
338                })
339                .collect(),
340            _ => Vec::new(),
341        };
342
343        let cutscene_list = match root.field("Mod_CutSceneList") {
344            Some(GffValue::List(elements)) => elements
345                .iter()
346                .map(|s| IfoCutScene {
347                    cutscene_name: get_resref(s, "CutScene_Name").unwrap_or_default(),
348                    cutscene_id: get_u32(s, "CutScene_ID").unwrap_or(0),
349                })
350                .collect(),
351            _ => Vec::new(),
352        };
353
354        let player_list = match root.field("Mod_PlayerList") {
355            Some(GffValue::List(elements)) => elements
356                .iter()
357                .map(|s| IfoPlayer {
358                    community_name: get_string(s, "Mod_CommntyName").unwrap_or_default(),
359                    first_name: get_locstring(s, "Mod_FirstName")
360                        .cloned()
361                        .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
362                    last_name: get_locstring(s, "Mod_LastName")
363                        .cloned()
364                        .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
365                    is_primary_player: get_bool(s, "Mod_IsPrimaryPlr").unwrap_or(false),
366                })
367                .collect(),
368            _ => Vec::new(),
369        };
370
371        let tokens_list = match root.field("Mod_Tokens") {
372            Some(GffValue::List(elements)) => elements
373                .iter()
374                .map(|s| IfoToken {
375                    token_number: get_u32(s, "Mod_TokensNumber").unwrap_or(0),
376                    token_value: get_string(s, "Mod_TokensValue").unwrap_or_default(),
377                })
378                .collect(),
379            _ => Vec::new(),
380        };
381
382        Ok(Self {
383            is_save_game,
384            is_nwm_file,
385            nwm_res_name,
386            module_id,
387            creator_id: get_i32(root, "Mod_Creator_ID").unwrap_or(0),
388            version: get_u32(root, "Mod_Version").unwrap_or(0),
389            tag: get_string(root, "Mod_Tag").unwrap_or_default(),
390            name: get_locstring(root, "Mod_Name")
391                .cloned()
392                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
393            description: get_locstring(root, "Mod_Description")
394                .cloned()
395                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
396            start_movie: get_resref(root, "Mod_StartMovie").unwrap_or_default(),
397            entry_area: get_resref(root, "Mod_Entry_Area").unwrap_or_default(),
398            entry_x: get_f32(root, "Mod_Entry_X").unwrap_or(0.0),
399            entry_y: get_f32(root, "Mod_Entry_Y").unwrap_or(0.0),
400            entry_z: get_f32(root, "Mod_Entry_Z").unwrap_or(0.0),
401            entry_dir_x: get_f32(root, "Mod_Entry_Dir_X").unwrap_or(0.0),
402            entry_dir_y: get_f32(root, "Mod_Entry_Dir_Y").unwrap_or(0.0),
403            min_per_hour: get_u8(root, "Mod_MinPerHour").unwrap_or(0),
404            dawn_hour: get_u8(root, "Mod_DawnHour").unwrap_or(0),
405            dusk_hour: get_u8(root, "Mod_DuskHour").unwrap_or(0),
406            xp_scale: get_u8(root, "Mod_XPScale").unwrap_or(10),
407            start_year: get_u32(root, "Mod_StartYear").unwrap_or(1340),
408            start_month: get_u8(root, "Mod_StartMonth").unwrap_or(6),
409            start_day: get_u8(root, "Mod_StartDay").unwrap_or(1),
410            start_hour: get_u8(root, "Mod_StartHour").unwrap_or(23),
411            start_minute: get_u16(root, "Mod_StartMinute").unwrap_or(0),
412            start_second: get_u16(root, "Mod_StartSecond").unwrap_or(0),
413            start_millisecond: get_u16(root, "Mod_StartMiliSec").unwrap_or(0),
414            transition: get_u32(root, "Mod_Transition").unwrap_or(0),
415            pause_time: get_u32(root, "Mod_PauseTime").unwrap_or(0),
416            pause_day: get_u32(root, "Mod_PauseDay").unwrap_or(0),
417            effect_next_id: get_u64(root, "Mod_Effect_NxtId").unwrap_or(0),
418            next_char_id_0: get_u32(root, "Mod_NextCharId0").unwrap_or(0),
419            next_char_id_1: get_u32(root, "Mod_NextCharId1").unwrap_or(0),
420            next_obj_id_0: get_u32(root, "Mod_NextObjId0").unwrap_or(0),
421            next_obj_id_1: get_u32(root, "Mod_NextObjId1").unwrap_or(0),
422            hak: get_string(root, "Mod_Hak").unwrap_or_default(),
423            on_heartbeat: get_resref(root, "Mod_OnHeartbeat").unwrap_or_default(),
424            on_user_defined: get_resref(root, "Mod_OnUsrDefined").unwrap_or_default(),
425            on_mod_load: get_resref(root, "Mod_OnModLoad").unwrap_or_default(),
426            on_mod_start: get_resref(root, "Mod_OnModStart").unwrap_or_default(),
427            on_client_enter: get_resref(root, "Mod_OnClientEntr").unwrap_or_default(),
428            on_client_leave: get_resref(root, "Mod_OnClientLeav").unwrap_or_default(),
429            on_activate_item: get_resref(root, "Mod_OnActvtItem").unwrap_or_default(),
430            on_acquire_item: get_resref(root, "Mod_OnAcquirItem").unwrap_or_default(),
431            on_unacquire_item: get_resref(root, "Mod_OnUnAqreItem").unwrap_or_default(),
432            on_player_death: get_resref(root, "Mod_OnPlrDeath").unwrap_or_default(),
433            on_player_dying: get_resref(root, "Mod_OnPlrDying").unwrap_or_default(),
434            on_spawn_btn_down: get_resref(root, "Mod_OnSpawnBtnDn").unwrap_or_default(),
435            on_player_rest: get_resref(root, "Mod_OnPlrRest").unwrap_or_default(),
436            on_player_level_up: get_resref(root, "Mod_OnPlrLvlUp").unwrap_or_default(),
437            on_equip_item: get_resref(root, "Mod_OnEquipItem").unwrap_or_default(),
438            areas,
439            expansion_list,
440            cutscene_list,
441            player_list,
442            tokens: tokens_list,
443        })
444    }
445
446    /// Converts this typed IFO value into a GFF container.
447    ///
448    /// Builds the GFF root from scratch using only modeled fields.
449    pub fn to_gff(&self) -> Gff {
450        let mut root = GffStruct::new(-1);
451
452        // --- Root flags ---
453        upsert_field(
454            &mut root,
455            "Mod_IsSaveGame",
456            GffValue::UInt8(u8::from(self.is_save_game)),
457        );
458        upsert_field(
459            &mut root,
460            "Mod_IsNWMFile",
461            GffValue::UInt8(u8::from(self.is_nwm_file)),
462        );
463        if self.is_nwm_file {
464            upsert_field(
465                &mut root,
466                "Mod_NWMResName",
467                GffValue::String(self.nwm_res_name.clone()),
468            );
469        }
470
471        // --- Module identity ---
472        upsert_field(
473            &mut root,
474            "Mod_ID",
475            GffValue::Binary(self.module_id.to_vec()),
476        );
477        upsert_field(
478            &mut root,
479            "Mod_Creator_ID",
480            GffValue::Int32(self.creator_id),
481        );
482        upsert_field(&mut root, "Mod_Version", GffValue::UInt32(self.version));
483        upsert_field(&mut root, "Mod_Tag", GffValue::String(self.tag.clone()));
484        upsert_field(
485            &mut root,
486            "Mod_Name",
487            GffValue::LocalizedString(self.name.clone()),
488        );
489        upsert_field(
490            &mut root,
491            "Mod_Description",
492            GffValue::LocalizedString(self.description.clone()),
493        );
494
495        // --- Entry point ---
496        upsert_field(
497            &mut root,
498            "Mod_StartMovie",
499            GffValue::ResRef(self.start_movie),
500        );
501        upsert_field(
502            &mut root,
503            "Mod_Entry_Area",
504            GffValue::ResRef(self.entry_area),
505        );
506        upsert_field(&mut root, "Mod_Entry_X", GffValue::Single(self.entry_x));
507        upsert_field(&mut root, "Mod_Entry_Y", GffValue::Single(self.entry_y));
508        upsert_field(&mut root, "Mod_Entry_Z", GffValue::Single(self.entry_z));
509        upsert_field(
510            &mut root,
511            "Mod_Entry_Dir_X",
512            GffValue::Single(self.entry_dir_x),
513        );
514        upsert_field(
515            &mut root,
516            "Mod_Entry_Dir_Y",
517            GffValue::Single(self.entry_dir_y),
518        );
519
520        // --- Time / day-night ---
521        upsert_field(
522            &mut root,
523            "Mod_MinPerHour",
524            GffValue::UInt8(self.min_per_hour),
525        );
526        upsert_field(&mut root, "Mod_DawnHour", GffValue::UInt8(self.dawn_hour));
527        upsert_field(&mut root, "Mod_DuskHour", GffValue::UInt8(self.dusk_hour));
528        upsert_field(&mut root, "Mod_XPScale", GffValue::UInt8(self.xp_scale));
529
530        // --- Save-game-only: calendar state ---
531        upsert_field(
532            &mut root,
533            "Mod_StartYear",
534            GffValue::UInt32(self.start_year),
535        );
536        upsert_field(
537            &mut root,
538            "Mod_StartMonth",
539            GffValue::UInt8(self.start_month),
540        );
541        upsert_field(&mut root, "Mod_StartDay", GffValue::UInt8(self.start_day));
542        upsert_field(&mut root, "Mod_StartHour", GffValue::UInt8(self.start_hour));
543        upsert_field(
544            &mut root,
545            "Mod_StartMinute",
546            GffValue::UInt16(self.start_minute),
547        );
548        upsert_field(
549            &mut root,
550            "Mod_StartSecond",
551            GffValue::UInt16(self.start_second),
552        );
553        upsert_field(
554            &mut root,
555            "Mod_StartMiliSec",
556            GffValue::UInt16(self.start_millisecond),
557        );
558        upsert_field(
559            &mut root,
560            "Mod_Transition",
561            GffValue::UInt32(self.transition),
562        );
563        upsert_field(
564            &mut root,
565            "Mod_PauseTime",
566            GffValue::UInt32(self.pause_time),
567        );
568        upsert_field(&mut root, "Mod_PauseDay", GffValue::UInt32(self.pause_day));
569
570        // --- Save-game-only: ID counters ---
571        upsert_field(
572            &mut root,
573            "Mod_Effect_NxtId",
574            GffValue::UInt64(self.effect_next_id),
575        );
576        upsert_field(
577            &mut root,
578            "Mod_NextCharId0",
579            GffValue::UInt32(self.next_char_id_0),
580        );
581        upsert_field(
582            &mut root,
583            "Mod_NextCharId1",
584            GffValue::UInt32(self.next_char_id_1),
585        );
586        upsert_field(
587            &mut root,
588            "Mod_NextObjId0",
589            GffValue::UInt32(self.next_obj_id_0),
590        );
591        upsert_field(
592            &mut root,
593            "Mod_NextObjId1",
594            GffValue::UInt32(self.next_obj_id_1),
595        );
596
597        // --- Save-game-only: Mod_Hak ---
598        if !self.hak.is_empty() {
599            upsert_field(&mut root, "Mod_Hak", GffValue::String(self.hak.clone()));
600        }
601
602        // --- Scripts ---
603        upsert_field(
604            &mut root,
605            "Mod_OnHeartbeat",
606            GffValue::ResRef(self.on_heartbeat),
607        );
608        upsert_field(
609            &mut root,
610            "Mod_OnUsrDefined",
611            GffValue::ResRef(self.on_user_defined),
612        );
613        upsert_field(
614            &mut root,
615            "Mod_OnModLoad",
616            GffValue::ResRef(self.on_mod_load),
617        );
618        upsert_field(
619            &mut root,
620            "Mod_OnModStart",
621            GffValue::ResRef(self.on_mod_start),
622        );
623        upsert_field(
624            &mut root,
625            "Mod_OnClientEntr",
626            GffValue::ResRef(self.on_client_enter),
627        );
628        upsert_field(
629            &mut root,
630            "Mod_OnClientLeav",
631            GffValue::ResRef(self.on_client_leave),
632        );
633        upsert_field(
634            &mut root,
635            "Mod_OnActvtItem",
636            GffValue::ResRef(self.on_activate_item),
637        );
638        upsert_field(
639            &mut root,
640            "Mod_OnAcquirItem",
641            GffValue::ResRef(self.on_acquire_item),
642        );
643        upsert_field(
644            &mut root,
645            "Mod_OnUnAqreItem",
646            GffValue::ResRef(self.on_unacquire_item),
647        );
648        upsert_field(
649            &mut root,
650            "Mod_OnPlrDeath",
651            GffValue::ResRef(self.on_player_death),
652        );
653        upsert_field(
654            &mut root,
655            "Mod_OnPlrDying",
656            GffValue::ResRef(self.on_player_dying),
657        );
658        upsert_field(
659            &mut root,
660            "Mod_OnSpawnBtnDn",
661            GffValue::ResRef(self.on_spawn_btn_down),
662        );
663        upsert_field(
664            &mut root,
665            "Mod_OnPlrRest",
666            GffValue::ResRef(self.on_player_rest),
667        );
668        upsert_field(
669            &mut root,
670            "Mod_OnPlrLvlUp",
671            GffValue::ResRef(self.on_player_level_up),
672        );
673        upsert_field(
674            &mut root,
675            "Mod_OnEquipItem",
676            GffValue::ResRef(self.on_equip_item),
677        );
678
679        // --- Area list ---
680        let area_structs: Vec<GffStruct> = self
681            .areas
682            .iter()
683            .map(|area| {
684                let mut s = GffStruct::new(6);
685                s.push_field("Area_Name", GffValue::ResRef(area.area_name));
686                if area.object_id != 0 {
687                    s.push_field("ObjectId", GffValue::UInt32(area.object_id));
688                }
689                s
690            })
691            .collect();
692        upsert_field(&mut root, "Mod_Area_list", GffValue::List(area_structs));
693
694        // --- Expansion list ---
695        let expansion_structs: Vec<GffStruct> = self
696            .expansion_list
697            .iter()
698            .map(|exp| {
699                let mut s = GffStruct::new(0);
700                s.push_field(
701                    "Expansion_Name",
702                    GffValue::LocalizedString(exp.expansion_name.clone()),
703                );
704                s.push_field("Expansion_ID", GffValue::Int32(exp.expansion_id));
705                s
706            })
707            .collect();
708        upsert_field(
709            &mut root,
710            "Mod_Expan_List",
711            GffValue::List(expansion_structs),
712        );
713
714        // --- Cutscene list ---
715        let cutscene_structs: Vec<GffStruct> = self
716            .cutscene_list
717            .iter()
718            .map(|cs| {
719                let mut s = GffStruct::new(1);
720                s.push_field("CutScene_Name", GffValue::ResRef(cs.cutscene_name));
721                s.push_field("CutScene_ID", GffValue::UInt32(cs.cutscene_id));
722                s
723            })
724            .collect();
725        upsert_field(
726            &mut root,
727            "Mod_CutSceneList",
728            GffValue::List(cutscene_structs),
729        );
730
731        // --- Player list ---
732        if !self.player_list.is_empty() {
733            let player_structs: Vec<GffStruct> = self
734                .player_list
735                .iter()
736                .map(|p| {
737                    let mut s = GffStruct::new(0);
738                    s.push_field(
739                        "Mod_CommntyName",
740                        GffValue::String(p.community_name.clone()),
741                    );
742                    s.push_field(
743                        "Mod_FirstName",
744                        GffValue::LocalizedString(p.first_name.clone()),
745                    );
746                    s.push_field(
747                        "Mod_LastName",
748                        GffValue::LocalizedString(p.last_name.clone()),
749                    );
750                    s.push_field(
751                        "Mod_IsPrimaryPlr",
752                        GffValue::UInt8(u8::from(p.is_primary_player)),
753                    );
754                    s
755                })
756                .collect();
757            upsert_field(&mut root, "Mod_PlayerList", GffValue::List(player_structs));
758        }
759
760        // --- Tokens ---
761        if !self.tokens.is_empty() {
762            let token_structs: Vec<GffStruct> = self
763                .tokens
764                .iter()
765                .map(|t| {
766                    let mut s = GffStruct::new(7);
767                    s.push_field("Mod_TokensNumber", GffValue::UInt32(t.token_number));
768                    s.push_field("Mod_TokensValue", GffValue::String(t.token_value.clone()));
769                    s
770                })
771                .collect();
772            upsert_field(&mut root, "Mod_Tokens", GffValue::List(token_structs));
773        }
774
775        Gff::new(*b"IFO ", root)
776    }
777}
778
779/// Errors produced while reading or writing typed IFO data.
780#[derive(Debug, Error)]
781pub enum IfoError {
782    /// Source file type is not supported by this parser.
783    #[error("unsupported IFO file type: {0:?}")]
784    UnsupportedFileType([u8; 4]),
785    /// Underlying GFF parser/writer error.
786    #[error(transparent)]
787    Gff(#[from] GffBinaryError),
788}
789
790/// Reads typed IFO data from a reader at the current stream position.
791#[cfg_attr(
792    feature = "tracing",
793    tracing::instrument(level = "debug", skip(reader))
794)]
795pub fn read_ifo<R: Read>(reader: &mut R) -> Result<Ifo, IfoError> {
796    let gff = read_gff(reader)?;
797    Ifo::from_gff(&gff)
798}
799
800/// Reads typed IFO data directly from bytes.
801#[cfg_attr(
802    feature = "tracing",
803    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
804)]
805pub fn read_ifo_from_bytes(bytes: &[u8]) -> Result<Ifo, IfoError> {
806    let gff = read_gff_from_bytes(bytes)?;
807    Ifo::from_gff(&gff)
808}
809
810/// Writes typed IFO data to an output writer.
811#[cfg_attr(
812    feature = "tracing",
813    tracing::instrument(level = "debug", skip(writer, ifo))
814)]
815pub fn write_ifo<W: Write>(writer: &mut W, ifo: &Ifo) -> Result<(), IfoError> {
816    let gff = ifo.to_gff();
817    write_gff(writer, &gff)?;
818    Ok(())
819}
820
821/// Serializes typed IFO data into a byte vector.
822#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(ifo)))]
823pub fn write_ifo_to_vec(ifo: &Ifo) -> Result<Vec<u8>, IfoError> {
824    let mut cursor = Cursor::new(Vec::new());
825    write_ifo(&mut cursor, ifo)?;
826    Ok(cursor.into_inner())
827}
828
829/// IFO `Mod_Expan_List` list entry child schema.
830static EXPANSION_LIST_CHILDREN: &[FieldSchema] = &[
831    FieldSchema {
832        label: "Expansion_Name",
833        expected_type: GffType::LocalizedString,
834        required: false,
835        children: None,
836        constraint: None,
837    },
838    FieldSchema {
839        label: "Expansion_ID",
840        expected_type: GffType::Int32,
841        required: false,
842        children: None,
843        constraint: None,
844    },
845];
846
847/// IFO `Mod_CutSceneList` list entry child schema.
848static CUTSCENE_LIST_CHILDREN: &[FieldSchema] = &[
849    FieldSchema {
850        label: "CutScene_Name",
851        expected_type: GffType::ResRef,
852        required: false,
853        children: None,
854        constraint: None,
855    },
856    FieldSchema {
857        label: "CutScene_ID",
858        expected_type: GffType::UInt32,
859        required: false,
860        children: None,
861        constraint: None,
862    },
863];
864
865/// IFO `Mod_Area_list` list entry child schema.
866static AREA_LIST_CHILDREN: &[FieldSchema] = &[
867    FieldSchema {
868        label: "Area_Name",
869        expected_type: GffType::ResRef,
870        required: false,
871        children: None,
872        constraint: None,
873    },
874    FieldSchema {
875        label: "ObjectId",
876        expected_type: GffType::UInt32,
877        required: false,
878        children: None,
879        constraint: None,
880    },
881];
882
883/// IFO `Mod_PlayerList` list entry child schema.
884static PLAYER_LIST_CHILDREN: &[FieldSchema] = &[
885    FieldSchema {
886        label: "Mod_CommntyName",
887        expected_type: GffType::String,
888        required: false,
889        children: None,
890        constraint: None,
891    },
892    FieldSchema {
893        label: "Mod_FirstName",
894        expected_type: GffType::LocalizedString,
895        required: false,
896        children: None,
897        constraint: None,
898    },
899    FieldSchema {
900        label: "Mod_LastName",
901        expected_type: GffType::LocalizedString,
902        required: false,
903        children: None,
904        constraint: None,
905    },
906    FieldSchema {
907        label: "Mod_IsPrimaryPlr",
908        expected_type: GffType::UInt8,
909        required: false,
910        children: None,
911        constraint: None,
912    },
913];
914
915/// IFO `Mod_Tokens` list entry child schema.
916static TOKENS_LIST_CHILDREN: &[FieldSchema] = &[
917    FieldSchema {
918        label: "Mod_TokensNumber",
919        expected_type: GffType::UInt32,
920        required: false,
921        children: None,
922        constraint: None,
923    },
924    FieldSchema {
925        label: "Mod_TokensValue",
926        expected_type: GffType::String,
927        required: false,
928        children: None,
929        constraint: None,
930    },
931];
932
933impl GffSchema for Ifo {
934    fn schema() -> &'static [FieldSchema] {
935        static SCHEMA: &[FieldSchema] = &[
936            // --- Module identity ---
937            FieldSchema {
938                label: "Mod_ID",
939                expected_type: GffType::Binary,
940                required: false,
941                children: None,
942                constraint: None,
943            },
944            FieldSchema {
945                label: "Mod_Creator_ID",
946                expected_type: GffType::Int32,
947                required: false,
948                children: None,
949                constraint: None,
950            },
951            FieldSchema {
952                label: "Mod_Version",
953                expected_type: GffType::UInt32,
954                required: false,
955                children: None,
956                constraint: None,
957            },
958            FieldSchema {
959                label: "Mod_Name",
960                expected_type: GffType::LocalizedString,
961                required: false,
962                children: None,
963                constraint: None,
964            },
965            FieldSchema {
966                label: "Mod_Description",
967                expected_type: GffType::LocalizedString,
968                required: false,
969                children: None,
970                constraint: None,
971            },
972            FieldSchema {
973                label: "Mod_Tag",
974                expected_type: GffType::String,
975                required: false,
976                children: None,
977                constraint: None,
978            },
979            FieldSchema {
980                label: "Mod_IsSaveGame",
981                expected_type: GffType::UInt8,
982                required: false,
983                children: None,
984                constraint: None,
985            },
986            FieldSchema {
987                label: "Mod_IsNWMFile",
988                expected_type: GffType::UInt8,
989                required: false,
990                children: None,
991                constraint: None,
992            },
993            FieldSchema {
994                label: "Mod_NWMResName",
995                expected_type: GffType::String,
996                required: false,
997                children: None,
998                constraint: None,
999            },
1000            // --- Entry point ---
1001            FieldSchema {
1002                label: "Mod_StartMovie",
1003                expected_type: GffType::ResRef,
1004                required: false,
1005                children: None,
1006                constraint: None,
1007            },
1008            FieldSchema {
1009                label: "Mod_Entry_Area",
1010                expected_type: GffType::ResRef,
1011                required: false,
1012                children: None,
1013                constraint: None,
1014            },
1015            FieldSchema {
1016                label: "Mod_Entry_X",
1017                expected_type: GffType::Single,
1018                required: false,
1019                children: None,
1020                constraint: None,
1021            },
1022            FieldSchema {
1023                label: "Mod_Entry_Y",
1024                expected_type: GffType::Single,
1025                required: false,
1026                children: None,
1027                constraint: None,
1028            },
1029            FieldSchema {
1030                label: "Mod_Entry_Z",
1031                expected_type: GffType::Single,
1032                required: false,
1033                children: None,
1034                constraint: None,
1035            },
1036            FieldSchema {
1037                label: "Mod_Entry_Dir_X",
1038                expected_type: GffType::Single,
1039                required: false,
1040                children: None,
1041                constraint: None,
1042            },
1043            FieldSchema {
1044                label: "Mod_Entry_Dir_Y",
1045                expected_type: GffType::Single,
1046                required: false,
1047                children: None,
1048                constraint: None,
1049            },
1050            // --- Time / day-night ---
1051            FieldSchema {
1052                label: "Mod_MinPerHour",
1053                expected_type: GffType::UInt8,
1054                required: false,
1055                children: None,
1056                constraint: None,
1057            },
1058            FieldSchema {
1059                label: "Mod_DawnHour",
1060                expected_type: GffType::UInt8,
1061                required: false,
1062                children: None,
1063                constraint: None,
1064            },
1065            FieldSchema {
1066                label: "Mod_DuskHour",
1067                expected_type: GffType::UInt8,
1068                required: false,
1069                children: None,
1070                constraint: None,
1071            },
1072            FieldSchema {
1073                label: "Mod_XPScale",
1074                expected_type: GffType::UInt8,
1075                required: false,
1076                children: None,
1077                constraint: None,
1078            },
1079            // --- Save-game-only: calendar state ---
1080            FieldSchema {
1081                label: "Mod_StartYear",
1082                expected_type: GffType::UInt32,
1083                required: false,
1084                children: None,
1085                constraint: None,
1086            },
1087            FieldSchema {
1088                label: "Mod_StartMonth",
1089                expected_type: GffType::UInt8,
1090                required: false,
1091                children: None,
1092                constraint: None,
1093            },
1094            FieldSchema {
1095                label: "Mod_StartDay",
1096                expected_type: GffType::UInt8,
1097                required: false,
1098                children: None,
1099                constraint: None,
1100            },
1101            FieldSchema {
1102                label: "Mod_StartHour",
1103                expected_type: GffType::UInt8,
1104                required: false,
1105                children: None,
1106                constraint: None,
1107            },
1108            FieldSchema {
1109                label: "Mod_Transition",
1110                expected_type: GffType::UInt32,
1111                required: false,
1112                children: None,
1113                constraint: None,
1114            },
1115            FieldSchema {
1116                label: "Mod_StartMinute",
1117                expected_type: GffType::UInt16,
1118                required: false,
1119                children: None,
1120                constraint: None,
1121            },
1122            FieldSchema {
1123                label: "Mod_StartSecond",
1124                expected_type: GffType::UInt16,
1125                required: false,
1126                children: None,
1127                constraint: None,
1128            },
1129            FieldSchema {
1130                label: "Mod_StartMiliSec",
1131                expected_type: GffType::UInt16,
1132                required: false,
1133                children: None,
1134                constraint: None,
1135            },
1136            FieldSchema {
1137                label: "Mod_PauseTime",
1138                expected_type: GffType::UInt32,
1139                required: false,
1140                children: None,
1141                constraint: None,
1142            },
1143            FieldSchema {
1144                label: "Mod_PauseDay",
1145                expected_type: GffType::UInt32,
1146                required: false,
1147                children: None,
1148                constraint: None,
1149            },
1150            // --- Save-game-only: ID counters ---
1151            FieldSchema {
1152                label: "Mod_Effect_NxtId",
1153                expected_type: GffType::UInt64,
1154                required: false,
1155                children: None,
1156                constraint: None,
1157            },
1158            FieldSchema {
1159                label: "Mod_NextCharId0",
1160                expected_type: GffType::UInt32,
1161                required: false,
1162                children: None,
1163                constraint: None,
1164            },
1165            FieldSchema {
1166                label: "Mod_NextCharId1",
1167                expected_type: GffType::UInt32,
1168                required: false,
1169                children: None,
1170                constraint: None,
1171            },
1172            FieldSchema {
1173                label: "Mod_NextObjId0",
1174                expected_type: GffType::UInt32,
1175                required: false,
1176                children: None,
1177                constraint: None,
1178            },
1179            FieldSchema {
1180                label: "Mod_NextObjId1",
1181                expected_type: GffType::UInt32,
1182                required: false,
1183                children: None,
1184                constraint: None,
1185            },
1186            // --- Save-game-only: Mod_Hak ---
1187            FieldSchema {
1188                label: "Mod_Hak",
1189                expected_type: GffType::String,
1190                required: false,
1191                children: None,
1192                constraint: None,
1193            },
1194            // --- Scripts (15 total, all ResRef) ---
1195            FieldSchema {
1196                label: "Mod_OnHeartbeat",
1197                expected_type: GffType::ResRef,
1198                required: false,
1199                children: None,
1200                constraint: None,
1201            },
1202            FieldSchema {
1203                label: "Mod_OnUsrDefined",
1204                expected_type: GffType::ResRef,
1205                required: false,
1206                children: None,
1207                constraint: None,
1208            },
1209            FieldSchema {
1210                label: "Mod_OnModLoad",
1211                expected_type: GffType::ResRef,
1212                required: false,
1213                children: None,
1214                constraint: None,
1215            },
1216            FieldSchema {
1217                label: "Mod_OnModStart",
1218                expected_type: GffType::ResRef,
1219                required: false,
1220                children: None,
1221                constraint: None,
1222            },
1223            FieldSchema {
1224                label: "Mod_OnClientEntr",
1225                expected_type: GffType::ResRef,
1226                required: false,
1227                children: None,
1228                constraint: None,
1229            },
1230            FieldSchema {
1231                label: "Mod_OnClientLeav",
1232                expected_type: GffType::ResRef,
1233                required: false,
1234                children: None,
1235                constraint: None,
1236            },
1237            FieldSchema {
1238                label: "Mod_OnActvtItem",
1239                expected_type: GffType::ResRef,
1240                required: false,
1241                children: None,
1242                constraint: None,
1243            },
1244            FieldSchema {
1245                label: "Mod_OnAcquirItem",
1246                expected_type: GffType::ResRef,
1247                required: false,
1248                children: None,
1249                constraint: None,
1250            },
1251            FieldSchema {
1252                label: "Mod_OnUnAqreItem",
1253                expected_type: GffType::ResRef,
1254                required: false,
1255                children: None,
1256                constraint: None,
1257            },
1258            FieldSchema {
1259                label: "Mod_OnPlrDeath",
1260                expected_type: GffType::ResRef,
1261                required: false,
1262                children: None,
1263                constraint: None,
1264            },
1265            FieldSchema {
1266                label: "Mod_OnPlrDying",
1267                expected_type: GffType::ResRef,
1268                required: false,
1269                children: None,
1270                constraint: None,
1271            },
1272            FieldSchema {
1273                label: "Mod_OnSpawnBtnDn",
1274                expected_type: GffType::ResRef,
1275                required: false,
1276                children: None,
1277                constraint: None,
1278            },
1279            FieldSchema {
1280                label: "Mod_OnPlrRest",
1281                expected_type: GffType::ResRef,
1282                required: false,
1283                children: None,
1284                constraint: None,
1285            },
1286            FieldSchema {
1287                label: "Mod_OnPlrLvlUp",
1288                expected_type: GffType::ResRef,
1289                required: false,
1290                children: None,
1291                constraint: None,
1292            },
1293            FieldSchema {
1294                label: "Mod_OnEquipItem",
1295                expected_type: GffType::ResRef,
1296                required: false,
1297                children: None,
1298                constraint: None,
1299            },
1300            // --- Lists ---
1301            FieldSchema {
1302                label: "Mod_Expan_List",
1303                expected_type: GffType::List,
1304                required: false,
1305                children: Some(EXPANSION_LIST_CHILDREN),
1306                constraint: None,
1307            },
1308            FieldSchema {
1309                label: "Mod_CutSceneList",
1310                expected_type: GffType::List,
1311                required: false,
1312                children: Some(CUTSCENE_LIST_CHILDREN),
1313                constraint: None,
1314            },
1315            FieldSchema {
1316                label: "Mod_Area_list",
1317                expected_type: GffType::List,
1318                required: false,
1319                children: Some(AREA_LIST_CHILDREN),
1320                constraint: None,
1321            },
1322            FieldSchema {
1323                label: "Mod_PlayerList",
1324                expected_type: GffType::List,
1325                required: false,
1326                children: Some(PLAYER_LIST_CHILDREN),
1327                constraint: None,
1328            },
1329            FieldSchema {
1330                label: "Mod_Tokens",
1331                expected_type: GffType::List,
1332                required: false,
1333                children: Some(TOKENS_LIST_CHILDREN),
1334                constraint: None,
1335            },
1336            // --- Variable tables (save-game-only, standard pattern) ---
1337            FieldSchema {
1338                label: "SWVarTable",
1339                expected_type: GffType::Struct,
1340                required: false,
1341                children: None,
1342                constraint: None,
1343            },
1344            FieldSchema {
1345                label: "VarTable",
1346                expected_type: GffType::List,
1347                required: false,
1348                children: None,
1349                constraint: None,
1350            },
1351        ];
1352        SCHEMA
1353    }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358    use super::*;
1359
1360    /// Build a minimal IFO GFF for testing.
1361    fn make_test_ifo_gff() -> Gff {
1362        let mut root = GffStruct::new(-1);
1363        root.push_field("Mod_IsSaveGame", GffValue::UInt8(0));
1364        root.push_field("Mod_IsNWMFile", GffValue::UInt8(0));
1365        root.push_field("Mod_ID", GffValue::Binary(vec![0xAB; 32]));
1366        root.push_field("Mod_Creator_ID", GffValue::Int32(42));
1367        root.push_field("Mod_Version", GffValue::UInt32(3));
1368        root.push_field("Mod_Tag", GffValue::String("end_m01aa".into()));
1369        root.push_field(
1370            "Mod_Name",
1371            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(42000))),
1372        );
1373        root.push_field(
1374            "Mod_Description",
1375            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(42001))),
1376        );
1377        root.push_field("Mod_StartMovie", GffValue::resref_lit("leclogo"));
1378        root.push_field("Mod_Entry_Area", GffValue::resref_lit("m01aa"));
1379        root.push_field("Mod_Entry_X", GffValue::Single(10.5));
1380        root.push_field("Mod_Entry_Y", GffValue::Single(20.3));
1381        root.push_field("Mod_Entry_Z", GffValue::Single(0.0));
1382        root.push_field("Mod_Entry_Dir_X", GffValue::Single(0.0));
1383        root.push_field("Mod_Entry_Dir_Y", GffValue::Single(1.0));
1384        root.push_field("Mod_MinPerHour", GffValue::UInt8(2));
1385        root.push_field("Mod_DawnHour", GffValue::UInt8(6));
1386        root.push_field("Mod_DuskHour", GffValue::UInt8(18));
1387        root.push_field("Mod_XPScale", GffValue::UInt8(10));
1388
1389        root.push_field("Mod_OnHeartbeat", GffValue::resref_lit("k_mod_hb"));
1390        root.push_field("Mod_OnUsrDefined", GffValue::resref_lit(""));
1391        root.push_field("Mod_OnModLoad", GffValue::resref_lit("k_mod_load"));
1392        root.push_field("Mod_OnModStart", GffValue::resref_lit("k_mod_start"));
1393        root.push_field("Mod_OnClientEntr", GffValue::resref_lit("k_mod_enter"));
1394        root.push_field("Mod_OnClientLeav", GffValue::resref_lit(""));
1395        root.push_field("Mod_OnActvtItem", GffValue::resref_lit("k_act_item"));
1396        root.push_field("Mod_OnAcquirItem", GffValue::resref_lit("k_acq_item"));
1397        root.push_field("Mod_OnUnAqreItem", GffValue::resref_lit(""));
1398        root.push_field("Mod_OnPlrDeath", GffValue::resref_lit("k_plr_death"));
1399        root.push_field("Mod_OnPlrDying", GffValue::resref_lit("k_plr_dying"));
1400        root.push_field("Mod_OnSpawnBtnDn", GffValue::resref_lit(""));
1401        root.push_field("Mod_OnPlrRest", GffValue::resref_lit("k_plr_rest"));
1402        root.push_field("Mod_OnPlrLvlUp", GffValue::resref_lit(""));
1403        root.push_field("Mod_OnEquipItem", GffValue::resref_lit(""));
1404
1405        // Area list with two areas.
1406        let mut a1 = GffStruct::new(6);
1407        a1.push_field("Area_Name", GffValue::resref_lit("m01aa"));
1408        let mut a2 = GffStruct::new(6);
1409        a2.push_field("Area_Name", GffValue::resref_lit("m01ab"));
1410        root.push_field("Mod_Area_list", GffValue::List(vec![a1, a2]));
1411
1412        // Empty expansion and cutscene lists.
1413        root.push_field("Mod_Expan_List", GffValue::List(Vec::new()));
1414        root.push_field("Mod_CutSceneList", GffValue::List(Vec::new()));
1415
1416        Gff::new(*b"IFO ", root)
1417    }
1418
1419    /// Build a save-game IFO GFF with all fields populated.
1420    fn make_save_game_ifo_gff() -> Gff {
1421        let mut gff = make_test_ifo_gff();
1422        // Flip save-game flag.
1423        for field in &mut gff.root.fields {
1424            if field.label == "Mod_IsSaveGame" {
1425                field.value = GffValue::UInt8(1);
1426            }
1427        }
1428
1429        // Save-game calendar state.
1430        gff.root.push_field("Mod_StartYear", GffValue::UInt32(1340));
1431        gff.root.push_field("Mod_StartMonth", GffValue::UInt8(6));
1432        gff.root.push_field("Mod_StartDay", GffValue::UInt8(1));
1433        gff.root.push_field("Mod_StartHour", GffValue::UInt8(23));
1434        gff.root.push_field("Mod_StartMinute", GffValue::UInt16(30));
1435        gff.root.push_field("Mod_StartSecond", GffValue::UInt16(15));
1436        gff.root
1437            .push_field("Mod_StartMiliSec", GffValue::UInt16(500));
1438        gff.root.push_field("Mod_Transition", GffValue::UInt32(1));
1439        gff.root.push_field("Mod_PauseTime", GffValue::UInt32(1000));
1440        gff.root.push_field("Mod_PauseDay", GffValue::UInt32(5));
1441
1442        // ID counters.
1443        gff.root
1444            .push_field("Mod_Effect_NxtId", GffValue::UInt64(999));
1445        gff.root.push_field("Mod_NextCharId0", GffValue::UInt32(10));
1446        gff.root.push_field("Mod_NextCharId1", GffValue::UInt32(20));
1447        gff.root.push_field("Mod_NextObjId0", GffValue::UInt32(100));
1448        gff.root.push_field("Mod_NextObjId1", GffValue::UInt32(200));
1449
1450        // Hak.
1451        gff.root
1452            .push_field("Mod_Hak", GffValue::String("my_hak".into()));
1453
1454        // Area with ObjectId.
1455        gff.root.fields.retain(|f| f.label != "Mod_Area_list");
1456        let mut a1 = GffStruct::new(6);
1457        a1.push_field("Area_Name", GffValue::resref_lit("m01aa"));
1458        a1.push_field("ObjectId", GffValue::UInt32(0x7F00_0001));
1459        gff.root
1460            .push_field("Mod_Area_list", GffValue::List(vec![a1]));
1461
1462        // Player list.
1463        let mut player = GffStruct::new(0);
1464        player.push_field("Mod_CommntyName", GffValue::String("TestPlayer".into()));
1465        use rakata_formats::GffLocalizedSubstring;
1466        let first = GffLocalizedString {
1467            string_ref: StrRef::invalid(),
1468            substrings: vec![GffLocalizedSubstring {
1469                string_id: 0,
1470                text: "Revan".into(),
1471            }],
1472        };
1473        player.push_field("Mod_FirstName", GffValue::LocalizedString(first));
1474        player.push_field(
1475            "Mod_LastName",
1476            GffValue::LocalizedString(GffLocalizedString::new(StrRef::invalid())),
1477        );
1478        player.push_field("Mod_IsPrimaryPlr", GffValue::UInt8(1));
1479        gff.root
1480            .push_field("Mod_PlayerList", GffValue::List(vec![player]));
1481
1482        // Tokens.
1483        let mut token = GffStruct::new(7);
1484        token.push_field("Mod_TokensNumber", GffValue::UInt32(0));
1485        token.push_field("Mod_TokensValue", GffValue::String("Revan".into()));
1486        gff.root
1487            .push_field("Mod_Tokens", GffValue::List(vec![token]));
1488
1489        // Expansion list.
1490        gff.root.fields.retain(|f| f.label != "Mod_Expan_List");
1491        let mut exp = GffStruct::new(0);
1492        exp.push_field(
1493            "Expansion_Name",
1494            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(100))),
1495        );
1496        exp.push_field("Expansion_ID", GffValue::Int32(1));
1497        gff.root
1498            .push_field("Mod_Expan_List", GffValue::List(vec![exp]));
1499
1500        // Cutscene list.
1501        gff.root.fields.retain(|f| f.label != "Mod_CutSceneList");
1502        let mut cs = GffStruct::new(1);
1503        cs.push_field("CutScene_Name", GffValue::resref_lit("cs_intro"));
1504        cs.push_field("CutScene_ID", GffValue::UInt32(0));
1505        gff.root
1506            .push_field("Mod_CutSceneList", GffValue::List(vec![cs]));
1507
1508        gff
1509    }
1510
1511    #[test]
1512    fn reads_core_ifo_fields() {
1513        let gff = make_test_ifo_gff();
1514        let ifo = Ifo::from_gff(&gff).expect("must parse");
1515
1516        assert_eq!(ifo.tag, "end_m01aa");
1517        assert_eq!(ifo.name.string_ref.raw(), 42000);
1518        assert_eq!(ifo.description.string_ref.raw(), 42001);
1519        assert_eq!(ifo.start_movie, "leclogo");
1520        assert_eq!(ifo.entry_area, "m01aa");
1521        assert_eq!(ifo.entry_x, 10.5);
1522        assert_eq!(ifo.entry_y, 20.3);
1523        assert_eq!(ifo.entry_z, 0.0);
1524        assert_eq!(ifo.entry_dir_x, 0.0);
1525        assert_eq!(ifo.entry_dir_y, 1.0);
1526        assert_eq!(ifo.min_per_hour, 2);
1527        assert_eq!(ifo.dawn_hour, 6);
1528        assert_eq!(ifo.dusk_hour, 18);
1529        assert_eq!(ifo.xp_scale, 10);
1530    }
1531
1532    #[test]
1533    fn reads_root_identity_fields() {
1534        let gff = make_test_ifo_gff();
1535        let ifo = Ifo::from_gff(&gff).expect("must parse");
1536
1537        assert!(!ifo.is_save_game);
1538        assert!(!ifo.is_nwm_file);
1539        assert!(ifo.nwm_res_name.is_empty());
1540        assert_eq!(ifo.module_id, [0xAB; 32]);
1541        assert_eq!(ifo.creator_id, 42);
1542        assert_eq!(ifo.version, 3);
1543    }
1544
1545    #[test]
1546    fn reads_scripts() {
1547        let gff = make_test_ifo_gff();
1548        let ifo = Ifo::from_gff(&gff).expect("must parse");
1549
1550        assert_eq!(ifo.on_heartbeat, "k_mod_hb");
1551        assert_eq!(ifo.on_user_defined, "");
1552        assert_eq!(ifo.on_mod_load, "k_mod_load");
1553        assert_eq!(ifo.on_mod_start, "k_mod_start");
1554        assert_eq!(ifo.on_client_enter, "k_mod_enter");
1555        assert_eq!(ifo.on_client_leave, "");
1556        assert_eq!(ifo.on_activate_item, "k_act_item");
1557        assert_eq!(ifo.on_acquire_item, "k_acq_item");
1558        assert_eq!(ifo.on_unacquire_item, "");
1559        assert_eq!(ifo.on_player_death, "k_plr_death");
1560        assert_eq!(ifo.on_player_dying, "k_plr_dying");
1561        assert_eq!(ifo.on_spawn_btn_down, "");
1562        assert_eq!(ifo.on_player_rest, "k_plr_rest");
1563        assert_eq!(ifo.on_player_level_up, "");
1564        assert_eq!(ifo.on_equip_item, "");
1565    }
1566
1567    #[test]
1568    fn reads_area_list() {
1569        let gff = make_test_ifo_gff();
1570        let ifo = Ifo::from_gff(&gff).expect("must parse");
1571
1572        assert_eq!(ifo.areas.len(), 2);
1573        assert_eq!(ifo.areas[0].area_name, "m01aa");
1574        assert_eq!(ifo.areas[0].object_id, 0);
1575        assert_eq!(ifo.areas[1].area_name, "m01ab");
1576        assert_eq!(ifo.areas[1].object_id, 0);
1577    }
1578
1579    #[test]
1580    fn reads_save_game_fields() {
1581        let gff = make_save_game_ifo_gff();
1582        let ifo = Ifo::from_gff(&gff).expect("must parse");
1583
1584        assert!(ifo.is_save_game);
1585        assert_eq!(ifo.start_year, 1340);
1586        assert_eq!(ifo.start_month, 6);
1587        assert_eq!(ifo.start_day, 1);
1588        assert_eq!(ifo.start_hour, 23);
1589        assert_eq!(ifo.start_minute, 30);
1590        assert_eq!(ifo.start_second, 15);
1591        assert_eq!(ifo.start_millisecond, 500);
1592        assert_eq!(ifo.transition, 1);
1593        assert_eq!(ifo.pause_time, 1000);
1594        assert_eq!(ifo.pause_day, 5);
1595        assert_eq!(ifo.effect_next_id, 999);
1596        assert_eq!(ifo.next_char_id_0, 10);
1597        assert_eq!(ifo.next_char_id_1, 20);
1598        assert_eq!(ifo.next_obj_id_0, 100);
1599        assert_eq!(ifo.next_obj_id_1, 200);
1600        assert_eq!(ifo.hak, "my_hak");
1601    }
1602
1603    #[test]
1604    fn reads_area_object_id() {
1605        let gff = make_save_game_ifo_gff();
1606        let ifo = Ifo::from_gff(&gff).expect("must parse");
1607
1608        assert_eq!(ifo.areas.len(), 1);
1609        assert_eq!(ifo.areas[0].area_name, "m01aa");
1610        assert_eq!(ifo.areas[0].object_id, 0x7F00_0001);
1611    }
1612
1613    #[test]
1614    fn reads_player_list() {
1615        let gff = make_save_game_ifo_gff();
1616        let ifo = Ifo::from_gff(&gff).expect("must parse");
1617
1618        assert_eq!(ifo.player_list.len(), 1);
1619        assert_eq!(ifo.player_list[0].community_name, "TestPlayer");
1620        assert!(ifo.player_list[0].is_primary_player);
1621        assert_eq!(ifo.player_list[0].first_name.substrings[0].text, "Revan");
1622    }
1623
1624    #[test]
1625    fn reads_tokens() {
1626        let gff = make_save_game_ifo_gff();
1627        let ifo = Ifo::from_gff(&gff).expect("must parse");
1628
1629        assert_eq!(ifo.tokens.len(), 1);
1630        assert_eq!(ifo.tokens[0].token_number, 0);
1631        assert_eq!(ifo.tokens[0].token_value, "Revan");
1632    }
1633
1634    #[test]
1635    fn reads_expansion_list() {
1636        let gff = make_save_game_ifo_gff();
1637        let ifo = Ifo::from_gff(&gff).expect("must parse");
1638
1639        assert_eq!(ifo.expansion_list.len(), 1);
1640        assert_eq!(ifo.expansion_list[0].expansion_name.string_ref.raw(), 100);
1641        assert_eq!(ifo.expansion_list[0].expansion_id, 1);
1642    }
1643
1644    #[test]
1645    fn reads_cutscene_list() {
1646        let gff = make_save_game_ifo_gff();
1647        let ifo = Ifo::from_gff(&gff).expect("must parse");
1648
1649        assert_eq!(ifo.cutscene_list.len(), 1);
1650        assert_eq!(ifo.cutscene_list[0].cutscene_name, "cs_intro");
1651        assert_eq!(ifo.cutscene_list[0].cutscene_id, 0);
1652    }
1653
1654    #[test]
1655    fn all_fields_survive_typed_roundtrip() {
1656        let gff = make_save_game_ifo_gff();
1657        let ifo = Ifo::from_gff(&gff).expect("typed parse");
1658        let bytes = write_ifo_to_vec(&ifo).expect("write succeeds");
1659        let reparsed = read_ifo_from_bytes(&bytes).expect("reparse succeeds");
1660
1661        assert_eq!(ifo, reparsed);
1662    }
1663
1664    #[test]
1665    fn typed_edits_roundtrip_through_gff_writer() {
1666        let gff = make_test_ifo_gff();
1667        let mut ifo = Ifo::from_gff(&gff).expect("must parse");
1668        ifo.tag = "end_m01ab".into();
1669        ifo.entry_area = ResRef::new("m01ab").expect("valid test resref");
1670        ifo.entry_x = 50.0;
1671        ifo.areas.push(IfoArea {
1672            area_name: ResRef::new("m01ac").expect("valid test resref"),
1673            object_id: 0,
1674        });
1675
1676        let bytes = write_ifo_to_vec(&ifo).expect("write succeeds");
1677        let reparsed = read_ifo_from_bytes(&bytes).expect("reparse succeeds");
1678
1679        assert_eq!(reparsed.tag, "end_m01ab");
1680        assert_eq!(reparsed.entry_area, "m01ab");
1681        assert_eq!(reparsed.entry_x, 50.0);
1682        assert_eq!(reparsed.areas.len(), 3);
1683        assert_eq!(reparsed.areas[2].area_name, "m01ac");
1684    }
1685
1686    #[test]
1687    fn read_ifo_from_reader_matches_bytes_path() {
1688        let gff = make_test_ifo_gff();
1689        let bytes = {
1690            let mut c = Cursor::new(Vec::new());
1691            write_gff(&mut c, &gff).expect("test fixture must be valid");
1692            c.into_inner()
1693        };
1694
1695        let mut cursor = Cursor::new(&bytes);
1696        let via_reader = read_ifo(&mut cursor).expect("reader parse succeeds");
1697        let via_bytes = read_ifo_from_bytes(&bytes).expect("bytes parse succeeds");
1698
1699        assert_eq!(via_reader, via_bytes);
1700    }
1701
1702    #[test]
1703    fn rejects_non_ifo_file_type() {
1704        let mut gff = make_test_ifo_gff();
1705        gff.file_type = *b"UTT ";
1706
1707        let err = Ifo::from_gff(&gff).expect_err("UTT must be rejected as IFO input");
1708        assert!(matches!(
1709            err,
1710            IfoError::UnsupportedFileType(file_type) if file_type == *b"UTT "
1711        ));
1712    }
1713
1714    #[test]
1715    fn write_ifo_matches_direct_gff_writer() {
1716        let gff = make_test_ifo_gff();
1717        let ifo = Ifo::from_gff(&gff).expect("must parse");
1718
1719        let via_typed = write_ifo_to_vec(&ifo).expect("typed write succeeds");
1720
1721        let mut direct = Cursor::new(Vec::new());
1722        write_gff(&mut direct, &ifo.to_gff()).expect("direct write succeeds");
1723
1724        assert_eq!(via_typed, direct.into_inner());
1725    }
1726
1727    #[test]
1728    fn empty_area_list_ok() {
1729        let mut gff = make_test_ifo_gff();
1730        gff.root.fields.retain(|f| f.label != "Mod_Area_list");
1731
1732        let ifo = Ifo::from_gff(&gff).expect("must parse");
1733        assert!(ifo.areas.is_empty());
1734    }
1735
1736    #[test]
1737    fn schema_field_count() {
1738        assert_eq!(Ifo::schema().len(), 58);
1739    }
1740
1741    #[test]
1742    fn schema_no_duplicate_labels() {
1743        let schema = Ifo::schema();
1744        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1745        labels.sort();
1746        let before = labels.len();
1747        labels.dedup();
1748        assert_eq!(before, labels.len(), "duplicate labels in IFO schema");
1749    }
1750
1751    #[test]
1752    fn schema_lists_have_children() {
1753        let schema = Ifo::schema();
1754        let expan = schema
1755            .iter()
1756            .find(|f| f.label == "Mod_Expan_List")
1757            .expect("test fixture must be valid");
1758        assert_eq!(expan.children.expect("test fixture must be valid").len(), 2);
1759        let cutscene = schema
1760            .iter()
1761            .find(|f| f.label == "Mod_CutSceneList")
1762            .expect("test fixture must be valid");
1763        assert_eq!(
1764            cutscene.children.expect("test fixture must be valid").len(),
1765            2
1766        );
1767        let area = schema
1768            .iter()
1769            .find(|f| f.label == "Mod_Area_list")
1770            .expect("test fixture must be valid");
1771        assert_eq!(area.children.expect("test fixture must be valid").len(), 2);
1772        let player = schema
1773            .iter()
1774            .find(|f| f.label == "Mod_PlayerList")
1775            .expect("test fixture must be valid");
1776        assert_eq!(
1777            player.children.expect("test fixture must be valid").len(),
1778            4
1779        );
1780        let tokens = schema
1781            .iter()
1782            .find(|f| f.label == "Mod_Tokens")
1783            .expect("test fixture must be valid");
1784        assert_eq!(
1785            tokens.children.expect("test fixture must be valid").len(),
1786            2
1787        );
1788    }
1789
1790    #[test]
1791    fn nwm_res_name_only_read_when_nwm_file() {
1792        let mut gff = make_test_ifo_gff();
1793        gff.root
1794            .push_field("Mod_NWMResName", GffValue::String("some_nwm".into()));
1795
1796        // With IsNWMFile=0, nwm_res_name should be empty.
1797        let ifo = Ifo::from_gff(&gff).expect("must parse");
1798        assert!(ifo.nwm_res_name.is_empty());
1799
1800        // Set IsNWMFile=1.
1801        for field in &mut gff.root.fields {
1802            if field.label == "Mod_IsNWMFile" {
1803                field.value = GffValue::UInt8(1);
1804            }
1805        }
1806        let ifo = Ifo::from_gff(&gff).expect("must parse");
1807        assert_eq!(ifo.nwm_res_name, "some_nwm");
1808    }
1809}