Skip to main content

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