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 module identity, entry point, time and script fields.
8//! - Typed handling for the area, expansion, cutscene, player and token lists.
9//!
10//! ## Three containers the view models nothing behind
11//!
12//! `Creature List`, `SWVarTable` and `VarTable` are save-game structures this
13//! view does not read into anything. They are declared so a file carrying them
14//! is not reported as holding an unrecognised label.
15
16use std::io::{Cursor, Read, Write};
17
18use crate::shared::ObjectId;
19use rakata_core::ResRef;
20use rakata_formats::gff::{get_string, upsert_field};
21use rakata_formats::gff_label;
22use rakata_formats::schema::FromGff;
23use rakata_formats::GENERIC_FILE_TYPE;
24use rakata_formats::{
25    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
26    GffStruct, GffValue,
27};
28use thiserror::Error;
29
30/// The struct id every `Mod_PlayerList` element in a saved module carries.
31///
32/// A hand-picked marker rather than an index or a count, which is what the
33/// hex says: the same family as `0x7f000000` for an invalid object and
34/// `0x4567` on a saved creature's `SpellsPerDayList`. What the engine does
35/// with it is unexamined.
36const SAVED_ROSTER_ELEMENT_ID: i32 = 0xbead;
37
38/// Typed IFO model built from/to [`Gff`] data.
39#[derive(Debug, Clone, PartialEq, GffModel)]
40#[gff_entry("Creature List", container = list, live, not_a_constant)]
41#[gff_entry(SWVarTable, container = nested, not_a_constant)]
42#[gff_entry(VarTable, container = list, not_a_constant)]
43pub struct Ifo {
44    /// Module ID blob (`Mod_ID`), kept at whatever length the file carries.
45    ///
46    /// Vanilla `module.ifo` carries 16 bytes; every savegame `module.ifo`
47    /// carries 32. The difference is an engine artefact rather than a format
48    /// rule: the loader reads this into a fixed 32-byte buffer it never
49    /// zeroes, and the writer always emits all 32 back, so a vanilla module
50    /// widens to 32 the first time the game saves it and the upper half is
51    /// whatever was in that memory.
52    ///
53    /// **Do not pad short values to 32 bytes to match.** That reproduces the
54    /// engine's own bug, and it is the tempting move for anyone who checks
55    /// only savegames, where 32 looks canonical. Reading 16 and writing 16
56    /// leaves the file exactly as its author wrote it. The engine never
57    /// consumes or compares this value, so neither behaviour affects play.
58    #[gff(Mod_ID, unexamined)]
59    pub module_id: Vec<u8>,
60    /// Creator ID (`Mod_Creator_ID`).
61    #[gff(Mod_Creator_ID, unexamined)]
62    pub creator_id: i32,
63    /// Module version (`Mod_Version`).
64    #[gff(Mod_Version, unexamined)]
65    pub version: u32,
66    /// Localized module name (`Mod_Name`).
67    #[gff(Mod_Name, unexamined)]
68    pub name: GffLocalizedString,
69    /// Localized module description (`Mod_Description`).
70    #[gff(Mod_Description, unexamined)]
71    pub description: GffLocalizedString,
72    /// Module tag (`Mod_Tag`).
73    #[gff(Mod_Tag, stamped)]
74    pub tag: String,
75    /// Whether this is a save game (`Mod_IsSaveGame`).
76    #[gff(Mod_IsSaveGame, constructed)]
77    pub is_save_game: bool,
78    /// Whether this is an NWM file (`Mod_IsNWMFile`).
79    #[gff(Mod_IsNWMFile, constructed)]
80    pub is_nwm_file: bool,
81    /// NWM resource name, only meaningful when `is_nwm_file` is true (`Mod_NWMResName`). If empty when flagged as NWM, execution becomes unstable.
82    #[gff(Mod_NWMResName, manual_write, manual_read, constructed)]
83    pub nwm_res_name: String,
84    /// Introductory movie (`Mod_StartMovie`).
85    #[gff(Mod_StartMovie, stamped)]
86    pub start_movie: ResRef,
87    /// Entry area resref (`Mod_Entry_Area`).
88    #[gff(Mod_Entry_Area, unexamined)]
89    pub entry_area: ResRef,
90    /// Entry X coordinate (`Mod_Entry_X`).
91    #[gff(Mod_Entry_X, unexamined)]
92    pub entry_x: f32,
93    /// Entry Y coordinate (`Mod_Entry_Y`).
94    #[gff(Mod_Entry_Y, unexamined)]
95    pub entry_y: f32,
96    /// Entry Z coordinate (`Mod_Entry_Z`).
97    #[gff(Mod_Entry_Z, unexamined)]
98    pub entry_z: f32,
99    /// 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).
100    #[gff(Mod_Entry_Dir_X, not_a_constant)]
101    pub entry_dir_x: f32,
102    /// 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).
103    #[gff(Mod_Entry_Dir_Y, not_a_constant)]
104    pub entry_dir_y: f32,
105    /// Minutes per game hour (`Mod_MinPerHour`). Reads `0` when absent.
106    #[gff(Mod_MinPerHour, stamped)]
107    pub min_per_hour: u8,
108    /// Dawn hour (`Mod_DawnHour`). If equal to dusk hour, module is locked into perpetual daylight. Reads `0` when absent.
109    #[gff(Mod_DawnHour, stamped)]
110    pub dawn_hour: u8,
111    /// Dusk hour (`Mod_DuskHour`). If equal to dawn hour, module is locked into perpetual daylight. Reads `0` when absent. 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.
112    #[gff(Mod_DuskHour, stamped)]
113    pub dusk_hour: u8,
114    /// XP scale multiplier (`Mod_XPScale`). Setting to 0 completely halts localized XP acquisition. If missing, defaults to 10.
115    #[gff(Mod_XPScale, stamped = 10)]
116    pub xp_scale: u8,
117    /// Game calendar year (`Mod_StartYear`, save-only, default 1340).
118    #[gff(Mod_StartYear, stamped = 1340)]
119    pub start_year: u32,
120    /// Calendar month (`Mod_StartMonth`, save-only, default 6).
121    #[gff(Mod_StartMonth, stamped = 6)]
122    pub start_month: u8,
123    /// Calendar day (`Mod_StartDay`, save-only, default 1).
124    #[gff(Mod_StartDay, stamped = 1)]
125    pub start_day: u8,
126    /// Calendar hour (`Mod_StartHour`, save-only, default 23).
127    #[gff(Mod_StartHour, stamped = 23)]
128    pub start_hour: u8,
129    /// Transition state (`Mod_Transition`, save-only, default 0).
130    #[gff(Mod_Transition, stamped)]
131    pub transition: u32,
132    /// Calendar minute (`Mod_StartMinute`, save-only, default 0).
133    #[gff(Mod_StartMinute, stamped)]
134    pub start_minute: u16,
135    /// Calendar second (`Mod_StartSecond`, save-only, default 0).
136    #[gff(Mod_StartSecond, stamped)]
137    pub start_second: u16,
138    /// Calendar millisecond (`Mod_StartMiliSec`, save-only, default 0).
139    #[gff(Mod_StartMiliSec, stamped)]
140    pub start_millisecond: u16,
141    /// Paused time-of-day in milliseconds (`Mod_PauseTime`, save-only, default 0).
142    #[gff(Mod_PauseTime, stamped)]
143    pub pause_time: u32,
144    /// Paused calendar day (`Mod_PauseDay`, save-only, default 0).
145    #[gff(Mod_PauseDay, stamped)]
146    pub pause_day: u32,
147    /// Next effect ID counter (`Mod_Effect_NxtId`, save-only).
148    #[gff(Mod_Effect_NxtId, stamped)]
149    pub effect_next_id: u64,
150    /// Next character ID low word (`Mod_NextCharId0`, save-only).
151    #[gff(Mod_NextCharId0, stamped)]
152    pub next_char_id_0: u32,
153    /// Next character ID high word (`Mod_NextCharId1`, save-only).
154    #[gff(Mod_NextCharId1, stamped)]
155    pub next_char_id_1: u32,
156    /// Next object ID low word (`Mod_NextObjId0`, save-only).
157    #[gff(Mod_NextObjId0, stamped)]
158    pub next_obj_id_0: u32,
159    /// Next object ID high word (`Mod_NextObjId1`, save-only).
160    #[gff(Mod_NextObjId1, stamped)]
161    pub next_obj_id_1: u32,
162    /// 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.
163    #[gff(Mod_Hak, manual_write, unexamined)]
164    pub hak: String,
165    /// On-heartbeat script (`Mod_OnHeartbeat`).
166    #[gff(Mod_OnHeartbeat, stamped)]
167    pub on_heartbeat: ResRef,
168    /// On-user-defined script (`Mod_OnUsrDefined`).
169    #[gff(Mod_OnUsrDefined, stamped)]
170    pub on_user_defined: ResRef,
171    /// On-module-load script (`Mod_OnModLoad`).
172    #[gff(Mod_OnModLoad, stamped)]
173    pub on_mod_load: ResRef,
174    /// On-module-start script (`Mod_OnModStart`).
175    #[gff(Mod_OnModStart, stamped)]
176    pub on_mod_start: ResRef,
177    /// On-client-enter script (`Mod_OnClientEntr`).
178    #[gff(Mod_OnClientEntr, stamped)]
179    pub on_client_enter: ResRef,
180    /// On-client-leave script (`Mod_OnClientLeav`).
181    #[gff(Mod_OnClientLeav, stamped)]
182    pub on_client_leave: ResRef,
183    /// On-activate-item script (`Mod_OnActvtItem`).
184    #[gff(Mod_OnActvtItem, stamped)]
185    pub on_activate_item: ResRef,
186    /// On-acquire-item script (`Mod_OnAcquirItem`).
187    #[gff(Mod_OnAcquirItem, stamped)]
188    pub on_acquire_item: ResRef,
189    /// On-unacquire-item script (`Mod_OnUnAqreItem`).
190    #[gff(Mod_OnUnAqreItem, stamped)]
191    pub on_unacquire_item: ResRef,
192    /// On-player-death script (`Mod_OnPlrDeath`).
193    #[gff(Mod_OnPlrDeath, stamped)]
194    pub on_player_death: ResRef,
195    /// On-player-dying script (`Mod_OnPlrDying`).
196    #[gff(Mod_OnPlrDying, stamped)]
197    pub on_player_dying: ResRef,
198    /// On-spawn-button-down script (`Mod_OnSpawnBtnDn`).
199    #[gff(Mod_OnSpawnBtnDn, stamped)]
200    pub on_spawn_btn_down: ResRef,
201    /// On-player-rest script (`Mod_OnPlrRest`).
202    #[gff(Mod_OnPlrRest, stamped)]
203    pub on_player_rest: ResRef,
204    /// On-player-level-up script (`Mod_OnPlrLvlUp`).
205    #[gff(Mod_OnPlrLvlUp, stamped)]
206    pub on_player_level_up: ResRef,
207    /// On-equip-item script (`Mod_OnEquipItem`). Asymmetric I/O: the engine loads this during `LoadModuleStart` but entirely omits it from `SaveModuleIFOStart` save-game serialization.
208    #[gff(Mod_OnEquipItem, stamped, omit = audited_constant(1057))]
209    pub on_equip_item: ResRef,
210    /// Expansion list (`Mod_Expan_List`).
211    #[gff(Mod_Expan_List, unexamined, list = IfoExpansion, element_id = 0)]
212    pub expansion_list: Vec<IfoExpansion>,
213    /// Cutscene list (`Mod_CutSceneList`).
214    #[gff(Mod_CutSceneList, unanswered, list = IfoCutScene, element_id = 1)]
215    pub cutscene_list: Vec<IfoCutScene>,
216    /// 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.
217    #[gff(Mod_Area_list, unanswered, list = IfoArea, element_id = 6)]
218    pub areas: Vec<IfoArea>,
219    /// Player list (`Mod_PlayerList`, save-only).
220    ///
221    /// Every element carries `0xbead`, a hand-picked marker rather than a
222    /// count, an index or a slot.
223    #[gff(Mod_PlayerList, manual_write, not_a_constant, list = IfoPlayer, element_id = 0xbead)]
224    pub player_list: Vec<IfoPlayer>,
225    /// Token list (`Mod_Tokens`).
226    #[gff(Mod_Tokens, manual_write, not_a_constant, list = IfoToken, element_id = 7)]
227    pub tokens: Vec<IfoToken>,
228}
229
230impl Ifo {
231    /// Creates an empty IFO value.
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Builds typed IFO data from a parsed GFF container.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`IfoError::UnsupportedFileType`] for a container that is
241    /// neither `IFO ` nor the generic `GFF ` form.
242    pub fn from_gff(gff: &Gff) -> Result<Self, IfoError> {
243        if gff.file_type != <Ifo as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
244            return Err(IfoError::UnsupportedFileType(gff.file_type));
245        }
246
247        let root = &gff.root;
248        let mut ifo = Self::read_declared(root);
249
250        // Only meaningful on a module carried over from Neverwinter Nights,
251        // and the flag saying so is a sibling, so the read is conditional
252        // rather than a plain fallback.
253        ifo.nwm_res_name = if ifo.is_nwm_file {
254            get_string(root, "Mod_NWMResName").unwrap_or_default()
255        } else {
256            String::new()
257        };
258
259        Ok(ifo)
260    }
261
262    /// The five labels the engine's own writer emits conditionally.
263    ///
264    /// Four are save-game state a module file does not carry, and the fifth is
265    /// an object id that only exists once the engine has allocated one. Writing
266    /// them unconditionally would put labels into a module file that no vanilla
267    /// one holds.
268    fn write_conditional(&self, root: &mut GffStruct) {
269        if self.is_nwm_file {
270            upsert_field(
271                root,
272                gff_label!("Mod_NWMResName"),
273                GffValue::String(self.nwm_res_name.clone()),
274            );
275        }
276        if !self.hak.is_empty() {
277            upsert_field(
278                root,
279                gff_label!("Mod_Hak"),
280                GffValue::String(self.hak.clone()),
281            );
282        }
283        if !self.player_list.is_empty() {
284            upsert_field(
285                root,
286                gff_label!("Mod_PlayerList"),
287                GffValue::List(
288                    self.player_list
289                        .iter()
290                        .map(|player| {
291                            let mut element = GffStruct::new(SAVED_ROSTER_ELEMENT_ID);
292                            player.write_element(&mut element);
293                            element
294                        })
295                        .collect(),
296                ),
297            );
298        }
299        if !self.tokens.is_empty() {
300            upsert_field(
301                root,
302                gff_label!("Mod_Tokens"),
303                GffValue::List(
304                    self.tokens
305                        .iter()
306                        .map(|token| {
307                            let mut element = GffStruct::new(7);
308                            token.write_element(&mut element);
309                            element
310                        })
311                        .collect(),
312                ),
313            );
314        }
315    }
316
317    /// Converts this typed IFO value into a GFF container.
318    pub fn to_gff(&self) -> Gff {
319        let mut root = GffStruct::new(-1);
320        self.write_declared(&mut root);
321        self.write_conditional(&mut root);
322        Gff::new(*b"IFO ", root)
323    }
324}
325
326/// Typed IFO one entry of `Mod_Area_list`.
327#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
328pub struct IfoArea {
329    /// Area resref (`Area_Name`).
330    #[gff(Area_Name, stamped)]
331    pub area_name: ResRef,
332    /// Object ID assigned at runtime in save games (`ObjectId`).
333    #[gff(ObjectId, manual_write, stamped = ObjectId::INVALID)]
334    pub object_id: ObjectId,
335}
336
337impl IfoArea {
338    /// Writes one `Mod_Area_list` element.
339    ///
340    /// The derive supplies no `write_element` for a type with a hand-written
341    /// write half, so this is where a list of these picks the gate up.
342    fn write_element(&self, structure: &mut GffStruct) {
343        self.write_declared(structure);
344        // Only a real allocated id is written. The sentinel means the engine
345        // has yet to assign one, and a module file carries no ids at all.
346        if self.object_id.is_valid() {
347            upsert_field(
348                structure,
349                gff_label!("ObjectId"),
350                GffValue::UInt32(self.object_id.get()),
351            );
352        }
353    }
354}
355
356/// Typed IFO one entry of `Mod_Expan_List`.
357#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
358pub struct IfoExpansion {
359    /// Localized expansion name (`Expansion_Name`).
360    #[gff(Expansion_Name, stamped)]
361    pub expansion_name: GffLocalizedString,
362    /// Expansion ID (`Expansion_ID`).
363    #[gff(Expansion_ID, stamped)]
364    pub expansion_id: i32,
365}
366
367/// Typed IFO one entry of `Mod_CutSceneList`.
368#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
369pub struct IfoCutScene {
370    /// Cutscene resref (`CutScene_Name`).
371    #[gff(CutScene_Name, stamped)]
372    pub cutscene_name: ResRef,
373    /// Cutscene ID (`CutScene_ID`).
374    #[gff(CutScene_ID, stamped)]
375    pub cutscene_id: u32,
376}
377
378/// Typed IFO one entry of `Mod_PlayerList`.
379#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
380pub struct IfoPlayer {
381    /// Community name (`Mod_CommntyName`).
382    #[gff(Mod_CommntyName, stamped)]
383    pub community_name: String,
384    /// Localized first name (`Mod_FirstName`).
385    #[gff(Mod_FirstName, stamped)]
386    pub first_name: GffLocalizedString,
387    /// Localized last name (`Mod_LastName`).
388    #[gff(Mod_LastName, stamped)]
389    pub last_name: GffLocalizedString,
390    /// Whether this is the primary player (`Mod_IsPrimaryPlr`).
391    #[gff(Mod_IsPrimaryPlr, stamped)]
392    pub is_primary_player: bool,
393}
394
395/// Typed IFO one entry of `Mod_Tokens`.
396#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
397pub struct IfoToken {
398    /// Token number (`Mod_TokensNumber`).
399    #[gff(Mod_TokensNumber, stamped)]
400    pub token_number: u32,
401    /// Token value (`Mod_TokensValue`).
402    #[gff(Mod_TokensValue, stamped)]
403    pub token_value: String,
404}
405
406/// Errors produced while reading or writing typed IFO data.
407#[derive(Debug, Error)]
408pub enum IfoError {
409    /// Source file type is not supported by this parser.
410    #[error("unsupported IFO file type: {0:?}")]
411    UnsupportedFileType([u8; 4]),
412    /// Underlying GFF parser/writer error.
413    #[error(transparent)]
414    Gff(#[from] GffBinaryError),
415}
416
417/// Reads typed IFO data from a reader at the current stream position.
418///
419/// # Errors
420///
421/// [`IfoError::Gff`] when the stream is not a readable GFF, and
422/// [`IfoError::UnsupportedFileType`] when it is a GFF of some other format,
423/// carrying the fourcc that was found.
424#[cfg_attr(
425    feature = "tracing",
426    tracing::instrument(level = "debug", skip(reader))
427)]
428pub fn read_ifo<R: Read>(reader: &mut R) -> Result<Ifo, IfoError> {
429    let gff = read_gff(reader)?;
430    Ifo::from_gff(&gff)
431}
432
433/// Reads typed IFO data directly from bytes.
434///
435/// # Errors
436///
437/// [`IfoError::Gff`] when `bytes` are not a readable GFF, and
438/// [`IfoError::UnsupportedFileType`] when they are a GFF of some other format,
439/// carrying the fourcc that was found.
440#[cfg_attr(
441    feature = "tracing",
442    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
443)]
444pub fn read_ifo_from_bytes(bytes: &[u8]) -> Result<Ifo, IfoError> {
445    let gff = read_gff_from_bytes(bytes)?;
446    Ifo::from_gff(&gff)
447}
448
449/// Authors the IFO file the typed view describes, into a writer.
450///
451/// # Errors
452///
453/// [`IfoError::Gff`] when the writer fails or a value will not encode. The
454/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
455/// this side.
456#[cfg_attr(
457    feature = "tracing",
458    tracing::instrument(level = "debug", skip(writer, ifo))
459)]
460pub fn author_ifo<W: Write>(writer: &mut W, ifo: &Ifo) -> Result<(), IfoError> {
461    let gff = ifo.to_gff();
462    write_gff(writer, &gff)?;
463    Ok(())
464}
465
466/// Authors the IFO file the typed view describes, as bytes.
467///
468/// # Errors
469///
470/// [`IfoError::Gff`] when a value will not encode. Writing into a `Vec` has no
471/// I/O to fail at.
472#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(ifo)))]
473pub fn author_ifo_to_vec(ifo: &Ifo) -> Result<Vec<u8>, IfoError> {
474    let mut cursor = Cursor::new(Vec::new());
475    author_ifo(&mut cursor, ifo)?;
476    Ok(cursor.into_inner())
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use rakata_core::StrRef;
483    use rakata_formats::schema::{HasSchema, Shape};
484    use rakata_formats::{gff_label, GffValue};
485
486    /// Build a minimal IFO GFF for testing.
487    fn make_test_ifo_gff() -> Gff {
488        let mut root = GffStruct::new(-1);
489        root.push_field(gff_label!("Mod_IsSaveGame"), GffValue::UInt8(0));
490        root.push_field(gff_label!("Mod_IsNWMFile"), GffValue::UInt8(0));
491        root.push_field(gff_label!("Mod_ID"), GffValue::Binary(vec![0xAB; 32]));
492        root.push_field(gff_label!("Mod_Creator_ID"), GffValue::Int32(42));
493        root.push_field(gff_label!("Mod_Version"), GffValue::UInt32(3));
494        root.push_field(gff_label!("Mod_Tag"), GffValue::String("end_m01aa".into()));
495        root.push_field(
496            gff_label!("Mod_Name"),
497            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(42000))),
498        );
499        root.push_field(
500            gff_label!("Mod_Description"),
501            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(42001))),
502        );
503        root.push_field(
504            gff_label!("Mod_StartMovie"),
505            GffValue::resref_lit("leclogo"),
506        );
507        root.push_field(gff_label!("Mod_Entry_Area"), GffValue::resref_lit("m01aa"));
508        root.push_field(gff_label!("Mod_Entry_X"), GffValue::Single(10.5));
509        root.push_field(gff_label!("Mod_Entry_Y"), GffValue::Single(20.3));
510        root.push_field(gff_label!("Mod_Entry_Z"), GffValue::Single(0.0));
511        root.push_field(gff_label!("Mod_Entry_Dir_X"), GffValue::Single(0.0));
512        root.push_field(gff_label!("Mod_Entry_Dir_Y"), GffValue::Single(1.0));
513        root.push_field(gff_label!("Mod_MinPerHour"), GffValue::UInt8(2));
514        root.push_field(gff_label!("Mod_DawnHour"), GffValue::UInt8(6));
515        root.push_field(gff_label!("Mod_DuskHour"), GffValue::UInt8(18));
516        root.push_field(gff_label!("Mod_XPScale"), GffValue::UInt8(10));
517
518        root.push_field(
519            gff_label!("Mod_OnHeartbeat"),
520            GffValue::resref_lit("k_mod_hb"),
521        );
522        root.push_field(gff_label!("Mod_OnUsrDefined"), GffValue::resref_lit(""));
523        root.push_field(
524            gff_label!("Mod_OnModLoad"),
525            GffValue::resref_lit("k_mod_load"),
526        );
527        root.push_field(
528            gff_label!("Mod_OnModStart"),
529            GffValue::resref_lit("k_mod_start"),
530        );
531        root.push_field(
532            gff_label!("Mod_OnClientEntr"),
533            GffValue::resref_lit("k_mod_enter"),
534        );
535        root.push_field(gff_label!("Mod_OnClientLeav"), GffValue::resref_lit(""));
536        root.push_field(
537            gff_label!("Mod_OnActvtItem"),
538            GffValue::resref_lit("k_act_item"),
539        );
540        root.push_field(
541            gff_label!("Mod_OnAcquirItem"),
542            GffValue::resref_lit("k_acq_item"),
543        );
544        root.push_field(gff_label!("Mod_OnUnAqreItem"), GffValue::resref_lit(""));
545        root.push_field(
546            gff_label!("Mod_OnPlrDeath"),
547            GffValue::resref_lit("k_plr_death"),
548        );
549        root.push_field(
550            gff_label!("Mod_OnPlrDying"),
551            GffValue::resref_lit("k_plr_dying"),
552        );
553        root.push_field(gff_label!("Mod_OnSpawnBtnDn"), GffValue::resref_lit(""));
554        root.push_field(
555            gff_label!("Mod_OnPlrRest"),
556            GffValue::resref_lit("k_plr_rest"),
557        );
558        root.push_field(gff_label!("Mod_OnPlrLvlUp"), GffValue::resref_lit(""));
559        root.push_field(gff_label!("Mod_OnEquipItem"), GffValue::resref_lit(""));
560
561        // Area list with two areas.
562        let mut a1 = GffStruct::new(6);
563        a1.push_field(gff_label!("Area_Name"), GffValue::resref_lit("m01aa"));
564        let mut a2 = GffStruct::new(6);
565        a2.push_field(gff_label!("Area_Name"), GffValue::resref_lit("m01ab"));
566        root.push_field(gff_label!("Mod_Area_list"), GffValue::List(vec![a1, a2]));
567
568        // Empty expansion and cutscene lists.
569        root.push_field(gff_label!("Mod_Expan_List"), GffValue::List(Vec::new()));
570        root.push_field(gff_label!("Mod_CutSceneList"), GffValue::List(Vec::new()));
571
572        Gff::new(*b"IFO ", root)
573    }
574
575    /// Build a save-game IFO GFF with all fields populated.
576    fn make_save_game_ifo_gff() -> Gff {
577        let mut gff = make_test_ifo_gff();
578        // Flip save-game flag.
579        for field in &mut gff.root.fields {
580            if field.label == "Mod_IsSaveGame" {
581                field.value = GffValue::UInt8(1);
582            }
583        }
584
585        // Save-game calendar state.
586        gff.root
587            .push_field(gff_label!("Mod_StartYear"), GffValue::UInt32(1340));
588        gff.root
589            .push_field(gff_label!("Mod_StartMonth"), GffValue::UInt8(6));
590        gff.root
591            .push_field(gff_label!("Mod_StartDay"), GffValue::UInt8(1));
592        gff.root
593            .push_field(gff_label!("Mod_StartHour"), GffValue::UInt8(23));
594        gff.root
595            .push_field(gff_label!("Mod_StartMinute"), GffValue::UInt16(30));
596        gff.root
597            .push_field(gff_label!("Mod_StartSecond"), GffValue::UInt16(15));
598        gff.root
599            .push_field(gff_label!("Mod_StartMiliSec"), GffValue::UInt16(500));
600        gff.root
601            .push_field(gff_label!("Mod_Transition"), GffValue::UInt32(1));
602        gff.root
603            .push_field(gff_label!("Mod_PauseTime"), GffValue::UInt32(1000));
604        gff.root
605            .push_field(gff_label!("Mod_PauseDay"), GffValue::UInt32(5));
606
607        // ID counters.
608        gff.root
609            .push_field(gff_label!("Mod_Effect_NxtId"), GffValue::UInt64(999));
610        gff.root
611            .push_field(gff_label!("Mod_NextCharId0"), GffValue::UInt32(10));
612        gff.root
613            .push_field(gff_label!("Mod_NextCharId1"), GffValue::UInt32(20));
614        gff.root
615            .push_field(gff_label!("Mod_NextObjId0"), GffValue::UInt32(100));
616        gff.root
617            .push_field(gff_label!("Mod_NextObjId1"), GffValue::UInt32(200));
618
619        // Hak.
620        gff.root
621            .push_field(gff_label!("Mod_Hak"), GffValue::String("my_hak".into()));
622
623        // Area with ObjectId.
624        gff.root.fields.retain(|f| f.label != "Mod_Area_list");
625        let mut a1 = GffStruct::new(6);
626        a1.push_field(gff_label!("Area_Name"), GffValue::resref_lit("m01aa"));
627        a1.push_field(gff_label!("ObjectId"), GffValue::UInt32(0x7F00_0001));
628        gff.root
629            .push_field(gff_label!("Mod_Area_list"), GffValue::List(vec![a1]));
630
631        // Player list.
632        let mut player = GffStruct::new(0);
633        player.push_field(
634            gff_label!("Mod_CommntyName"),
635            GffValue::String("TestPlayer".into()),
636        );
637        use rakata_formats::GffLocalizedSubstring;
638        let first = GffLocalizedString {
639            string_ref: StrRef::invalid(),
640            substrings: vec![GffLocalizedSubstring {
641                string_id: 0,
642                text: "Revan".into(),
643            }],
644        };
645        player.push_field(
646            gff_label!("Mod_FirstName"),
647            GffValue::LocalizedString(first),
648        );
649        player.push_field(
650            gff_label!("Mod_LastName"),
651            GffValue::LocalizedString(GffLocalizedString::new(StrRef::invalid())),
652        );
653        player.push_field(gff_label!("Mod_IsPrimaryPlr"), GffValue::UInt8(1));
654        gff.root
655            .push_field(gff_label!("Mod_PlayerList"), GffValue::List(vec![player]));
656
657        // Tokens.
658        let mut token = GffStruct::new(7);
659        token.push_field(gff_label!("Mod_TokensNumber"), GffValue::UInt32(0));
660        token.push_field(
661            gff_label!("Mod_TokensValue"),
662            GffValue::String("Revan".into()),
663        );
664        gff.root
665            .push_field(gff_label!("Mod_Tokens"), GffValue::List(vec![token]));
666
667        // Expansion list.
668        gff.root.fields.retain(|f| f.label != "Mod_Expan_List");
669        let mut exp = GffStruct::new(0);
670        exp.push_field(
671            gff_label!("Expansion_Name"),
672            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(100))),
673        );
674        exp.push_field(gff_label!("Expansion_ID"), GffValue::Int32(1));
675        gff.root
676            .push_field(gff_label!("Mod_Expan_List"), GffValue::List(vec![exp]));
677
678        // Cutscene list.
679        gff.root.fields.retain(|f| f.label != "Mod_CutSceneList");
680        let mut cs = GffStruct::new(1);
681        cs.push_field(
682            gff_label!("CutScene_Name"),
683            GffValue::resref_lit("cs_intro"),
684        );
685        cs.push_field(gff_label!("CutScene_ID"), GffValue::UInt32(0));
686        gff.root
687            .push_field(gff_label!("Mod_CutSceneList"), GffValue::List(vec![cs]));
688
689        gff
690    }
691
692    #[test]
693    fn reads_core_ifo_fields() {
694        let gff = make_test_ifo_gff();
695        let ifo = Ifo::from_gff(&gff).expect("must parse");
696
697        assert_eq!(ifo.tag, "end_m01aa");
698        assert_eq!(ifo.name.string_ref.raw(), 42000);
699        assert_eq!(ifo.description.string_ref.raw(), 42001);
700        assert_eq!(ifo.start_movie, "leclogo");
701        assert_eq!(ifo.entry_area, "m01aa");
702        assert_eq!(ifo.entry_x, 10.5);
703        assert_eq!(ifo.entry_y, 20.3);
704        assert_eq!(ifo.entry_z, 0.0);
705        assert_eq!(ifo.entry_dir_x, 0.0);
706        assert_eq!(ifo.entry_dir_y, 1.0);
707        assert_eq!(ifo.min_per_hour, 2);
708        assert_eq!(ifo.dawn_hour, 6);
709        assert_eq!(ifo.dusk_hour, 18);
710        assert_eq!(ifo.xp_scale, 10);
711    }
712
713    #[test]
714    fn reads_root_identity_fields() {
715        let gff = make_test_ifo_gff();
716        let ifo = Ifo::from_gff(&gff).expect("must parse");
717
718        assert!(!ifo.is_save_game);
719        assert!(!ifo.is_nwm_file);
720        assert!(ifo.nwm_res_name.is_empty());
721        assert_eq!(ifo.module_id, vec![0xAB; 32]);
722        assert_eq!(ifo.creator_id, 42);
723        assert_eq!(ifo.version, 3);
724    }
725
726    #[test]
727    fn a_module_id_of_any_length_survives_a_round_trip() {
728        // Vanilla `module.ifo` carries 16 bytes here and a savegame carries
729        // 32. Requiring one length replaced the other with zeros, silently,
730        // on every vanilla module in the game.
731        for length in [0, 1, 15, 16, 17, 31, 32, 33, 64] {
732            let mut root = GffStruct::new(-1);
733            root.push_field(gff_label!("Mod_ID"), GffValue::Binary(vec![0xCD; length]));
734            let ifo = Ifo::from_gff(&Gff::new(*b"IFO ", root)).expect("must parse");
735
736            assert_eq!(
737                ifo.module_id,
738                vec![0xCD; length],
739                "a {length}-byte Mod_ID must survive the read"
740            );
741            assert_eq!(
742                Ifo::from_gff(&ifo.to_gff()).expect("must parse").module_id,
743                vec![0xCD; length],
744                "a {length}-byte Mod_ID must survive the write"
745            );
746        }
747    }
748
749    #[test]
750    fn reads_scripts() {
751        let gff = make_test_ifo_gff();
752        let ifo = Ifo::from_gff(&gff).expect("must parse");
753
754        assert_eq!(ifo.on_heartbeat, "k_mod_hb");
755        assert_eq!(ifo.on_user_defined, "");
756        assert_eq!(ifo.on_mod_load, "k_mod_load");
757        assert_eq!(ifo.on_mod_start, "k_mod_start");
758        assert_eq!(ifo.on_client_enter, "k_mod_enter");
759        assert_eq!(ifo.on_client_leave, "");
760        assert_eq!(ifo.on_activate_item, "k_act_item");
761        assert_eq!(ifo.on_acquire_item, "k_acq_item");
762        assert_eq!(ifo.on_unacquire_item, "");
763        assert_eq!(ifo.on_player_death, "k_plr_death");
764        assert_eq!(ifo.on_player_dying, "k_plr_dying");
765        assert_eq!(ifo.on_spawn_btn_down, "");
766        assert_eq!(ifo.on_player_rest, "k_plr_rest");
767        assert_eq!(ifo.on_player_level_up, "");
768        assert_eq!(ifo.on_equip_item, "");
769    }
770
771    #[test]
772    fn reads_area_list() {
773        let gff = make_test_ifo_gff();
774        let ifo = Ifo::from_gff(&gff).expect("must parse");
775
776        assert_eq!(ifo.areas.len(), 2);
777        assert_eq!(ifo.areas[0].area_name, "m01aa");
778        // Absent, so the placeholder rather than object zero. This test
779        // asserted `0` while the reader defaulted there, which is a test and
780        // an implementation sharing one mistake rather than confirming each
781        // other.
782        assert_eq!(ifo.areas[0].object_id, ObjectId::INVALID);
783        assert_eq!(ifo.areas[1].area_name, "m01ab");
784        assert_eq!(ifo.areas[1].object_id, ObjectId::INVALID);
785    }
786
787    #[test]
788    fn reads_save_game_fields() {
789        let gff = make_save_game_ifo_gff();
790        let ifo = Ifo::from_gff(&gff).expect("must parse");
791
792        assert!(ifo.is_save_game);
793        assert_eq!(ifo.start_year, 1340);
794        assert_eq!(ifo.start_month, 6);
795        assert_eq!(ifo.start_day, 1);
796        assert_eq!(ifo.start_hour, 23);
797        assert_eq!(ifo.start_minute, 30);
798        assert_eq!(ifo.start_second, 15);
799        assert_eq!(ifo.start_millisecond, 500);
800        assert_eq!(ifo.transition, 1);
801        assert_eq!(ifo.pause_time, 1000);
802        assert_eq!(ifo.pause_day, 5);
803        assert_eq!(ifo.effect_next_id, 999);
804        assert_eq!(ifo.next_char_id_0, 10);
805        assert_eq!(ifo.next_char_id_1, 20);
806        assert_eq!(ifo.next_obj_id_0, 100);
807        assert_eq!(ifo.next_obj_id_1, 200);
808        assert_eq!(ifo.hak, "my_hak");
809    }
810
811    #[test]
812    fn reads_area_object_id() {
813        let gff = make_save_game_ifo_gff();
814        let ifo = Ifo::from_gff(&gff).expect("must parse");
815
816        assert_eq!(ifo.areas.len(), 1);
817        assert_eq!(ifo.areas[0].area_name, "m01aa");
818        assert_eq!(ifo.areas[0].object_id, ObjectId::new(0x7F00_0001));
819    }
820
821    #[test]
822    fn reads_player_list() {
823        let gff = make_save_game_ifo_gff();
824        let ifo = Ifo::from_gff(&gff).expect("must parse");
825
826        assert_eq!(ifo.player_list.len(), 1);
827        assert_eq!(ifo.player_list[0].community_name, "TestPlayer");
828        assert!(ifo.player_list[0].is_primary_player);
829        assert_eq!(ifo.player_list[0].first_name.substrings[0].text, "Revan");
830    }
831
832    #[test]
833    fn reads_tokens() {
834        let gff = make_save_game_ifo_gff();
835        let ifo = Ifo::from_gff(&gff).expect("must parse");
836
837        assert_eq!(ifo.tokens.len(), 1);
838        assert_eq!(ifo.tokens[0].token_number, 0);
839        assert_eq!(ifo.tokens[0].token_value, "Revan");
840    }
841
842    #[test]
843    fn reads_expansion_list() {
844        let gff = make_save_game_ifo_gff();
845        let ifo = Ifo::from_gff(&gff).expect("must parse");
846
847        assert_eq!(ifo.expansion_list.len(), 1);
848        assert_eq!(ifo.expansion_list[0].expansion_name.string_ref.raw(), 100);
849        assert_eq!(ifo.expansion_list[0].expansion_id, 1);
850    }
851
852    #[test]
853    fn reads_cutscene_list() {
854        let gff = make_save_game_ifo_gff();
855        let ifo = Ifo::from_gff(&gff).expect("must parse");
856
857        assert_eq!(ifo.cutscene_list.len(), 1);
858        assert_eq!(ifo.cutscene_list[0].cutscene_name, "cs_intro");
859        assert_eq!(ifo.cutscene_list[0].cutscene_id, 0);
860    }
861
862    #[test]
863    fn all_fields_survive_typed_roundtrip() {
864        let gff = make_save_game_ifo_gff();
865        let ifo = Ifo::from_gff(&gff).expect("typed parse");
866        let bytes = author_ifo_to_vec(&ifo).expect("write succeeds");
867        let reparsed = read_ifo_from_bytes(&bytes).expect("reparse succeeds");
868
869        assert_eq!(ifo, reparsed);
870    }
871
872    #[test]
873    fn typed_edits_roundtrip_through_gff_writer() {
874        let gff = make_test_ifo_gff();
875        let mut ifo = Ifo::from_gff(&gff).expect("must parse");
876        ifo.tag = "end_m01ab".into();
877        ifo.entry_area = ResRef::new("m01ab").expect("valid test resref");
878        ifo.entry_x = 50.0;
879        ifo.areas.push(IfoArea {
880            area_name: ResRef::new("m01ac").expect("valid test resref"),
881            object_id: ObjectId::new(0),
882        });
883
884        let bytes = author_ifo_to_vec(&ifo).expect("write succeeds");
885        let reparsed = read_ifo_from_bytes(&bytes).expect("reparse succeeds");
886
887        assert_eq!(reparsed.tag, "end_m01ab");
888        assert_eq!(reparsed.entry_area, "m01ab");
889        assert_eq!(reparsed.entry_x, 50.0);
890        assert_eq!(reparsed.areas.len(), 3);
891        assert_eq!(reparsed.areas[2].area_name, "m01ac");
892    }
893
894    #[test]
895    fn read_ifo_from_reader_matches_bytes_path() {
896        let gff = make_test_ifo_gff();
897        let bytes = {
898            let mut c = Cursor::new(Vec::new());
899            write_gff(&mut c, &gff).expect("test fixture must be valid");
900            c.into_inner()
901        };
902
903        let mut cursor = Cursor::new(&bytes);
904        let via_reader = read_ifo(&mut cursor).expect("reader parse succeeds");
905        let via_bytes = read_ifo_from_bytes(&bytes).expect("bytes parse succeeds");
906
907        assert_eq!(via_reader, via_bytes);
908    }
909
910    #[test]
911    fn rejects_non_ifo_file_type() {
912        let mut gff = make_test_ifo_gff();
913        gff.file_type = *b"UTT ";
914
915        let err = Ifo::from_gff(&gff).expect_err("UTT must be rejected as IFO input");
916        assert!(matches!(
917            err,
918            IfoError::UnsupportedFileType(file_type) if file_type == *b"UTT "
919        ));
920    }
921
922    #[test]
923    fn write_ifo_matches_direct_gff_writer() {
924        let gff = make_test_ifo_gff();
925        let ifo = Ifo::from_gff(&gff).expect("must parse");
926
927        let via_typed = author_ifo_to_vec(&ifo).expect("typed write succeeds");
928
929        let mut direct = Cursor::new(Vec::new());
930        write_gff(&mut direct, &ifo.to_gff()).expect("direct write succeeds");
931
932        assert_eq!(via_typed, direct.into_inner());
933    }
934
935    #[test]
936    fn empty_area_list_ok() {
937        let mut gff = make_test_ifo_gff();
938        gff.root.fields.retain(|f| f.label != "Mod_Area_list");
939
940        let ifo = Ifo::from_gff(&gff).expect("must parse");
941        assert!(ifo.areas.is_empty());
942    }
943
944    #[test]
945    fn schema_field_count() {
946        assert_eq!(Ifo::schema().len(), 59);
947    }
948
949    #[test]
950    fn schema_no_duplicate_labels() {
951        let mut labels: Vec<&str> = Ifo::schema().iter().map(|f| f.label.as_str()).collect();
952        labels.sort_unstable();
953        let before = labels.len();
954        labels.dedup();
955        assert_eq!(before, labels.len(), "duplicate labels in IFO schema");
956    }
957
958    #[test]
959    fn schema_lists_carry_their_elements() {
960        let element_count = |label: &str| {
961            let field = Ifo::schema()
962                .iter()
963                .find(|f| f.label.as_str() == label)
964                .expect("declared");
965            match field.shape {
966                // Summed across parts: a schema is a sequence so a shared part
967                // can be referenced rather than copied.
968                Shape::List { element, .. } => element.iter().map(|p| p.len()).sum::<usize>(),
969                other => panic!("{label} is a list, got {other:?}"),
970            }
971        };
972        assert_eq!(element_count("Mod_Expan_List"), 2);
973        assert_eq!(element_count("Mod_CutSceneList"), 2);
974        assert_eq!(element_count("Mod_Area_list"), 2);
975        assert_eq!(element_count("Mod_PlayerList"), 4);
976        assert_eq!(element_count("Mod_Tokens"), 2);
977
978        // The three the view models nothing behind still have to be
979        // recognised, so they are lists and structs with no child schema.
980        assert_eq!(element_count("Creature List"), 0);
981        assert_eq!(element_count("VarTable"), 0);
982        let Shape::Struct { fields } = Ifo::schema()
983            .iter()
984            .find(|f| f.label.as_str() == "SWVarTable")
985            .expect("declared")
986            .shape
987        else {
988            panic!("SWVarTable is a struct");
989        };
990        assert!(fields.is_empty());
991    }
992}