rakata_formats/erf/
reader.rs

1//! ERF/MOD/HAK binary reader.
2
3use std::io::{Cursor, Read};
4
5use rakata_core::decode_text_strict;
6
7use super::{
8    binary, Erf, ErfBinaryError, ErfFileType, ErfLocalizedString, ErfReadMode, ErfReadOptions,
9    ErfResource, ERF_TEXT_ENCODING, ERF_VERSION_V10, ERF_VERSION_V11, FILE_HEADER_SIZE,
10    KEY_ENTRY_SIZE, RESOURCE_ENTRY_SIZE,
11};
12use rakata_core::{LanguageId, ResRef, ResourceTypeCode, StrRef};
13
14/// Reads an ERF/MOD/HAK archive from a reader.
15///
16/// The stream is consumed from its current position.
17#[cfg_attr(
18    feature = "tracing",
19    tracing::instrument(level = "debug", skip(reader))
20)]
21pub fn read_erf<R: Read>(reader: &mut R) -> Result<Erf, ErfBinaryError> {
22    read_erf_with_options(reader, ErfReadOptions::default())
23}
24
25/// Reads an ERF-family archive from a reader with explicit read options.
26#[cfg_attr(
27    feature = "tracing",
28    tracing::instrument(level = "debug", skip(reader))
29)]
30pub fn read_erf_with_options<R: Read>(
31    reader: &mut R,
32    options: ErfReadOptions,
33) -> Result<Erf, ErfBinaryError> {
34    let mut bytes = Vec::new();
35    reader.read_to_end(&mut bytes)?;
36    crate::trace_debug!(bytes_len = bytes.len(), "read erf-family bytes from reader");
37    read_erf_from_bytes_with_options(&bytes, options)
38}
39
40/// Reads an ERF/MOD/HAK archive from bytes.
41#[cfg_attr(
42    feature = "tracing",
43    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
44)]
45pub fn read_erf_from_bytes(bytes: &[u8]) -> Result<Erf, ErfBinaryError> {
46    read_erf_from_bytes_with_options(bytes, ErfReadOptions::default())
47}
48
49/// Reads an ERF-family archive from bytes with explicit read options.
50#[cfg_attr(
51    feature = "tracing",
52    tracing::instrument(
53        level = "debug",
54        skip(bytes),
55        fields(bytes_len = bytes.len(), input_mode = ?options.input)
56    )
57)]
58pub fn read_erf_from_bytes_with_options(
59    bytes: &[u8],
60    options: ErfReadOptions,
61) -> Result<Erf, ErfBinaryError> {
62    if bytes.len() < FILE_HEADER_SIZE {
63        return Err(ErfBinaryError::InvalidHeader(
64            "file smaller than ERF header".into(),
65        ));
66    }
67
68    let magic = binary::read_fourcc(bytes, 0)?;
69    let file_type = ErfFileType::from_fourcc_with_mode(magic, options.input)
70        .ok_or(ErfBinaryError::InvalidMagic(magic))?;
71    let version = binary::read_fourcc(bytes, 4)?;
72    match options.input {
73        ErfReadMode::CanonicalK1 => {
74            binary::expect_fourcc(version, ERF_VERSION_V10).map_err(ErfBinaryError::InvalidVersion)
75        }
76        ErfReadMode::CompatibilityAurora => {
77            if version == ERF_VERSION_V10 || version == ERF_VERSION_V11 {
78                Ok(())
79            } else {
80                Err(ErfBinaryError::InvalidVersion(version))
81            }
82        }
83    }?;
84
85    let language_count = binary::checked_to_usize(binary::read_u32(bytes, 8)?, "language_count")?;
86    let localized_string_size =
87        binary::checked_to_usize(binary::read_u32(bytes, 12)?, "localized_string_size")?;
88    let entry_count = binary::checked_to_usize(binary::read_u32(bytes, 16)?, "entry_count")?;
89    let localized_strings_offset =
90        binary::checked_to_usize(binary::read_u32(bytes, 20)?, "localized_strings_offset")?;
91    let mut keys_offset = binary::checked_to_usize(binary::read_u32(bytes, 24)?, "keys_offset")?;
92    let mut resources_offset =
93        binary::checked_to_usize(binary::read_u32(bytes, 28)?, "resources_offset")?;
94    let build_year = binary::read_u32(bytes, 32)?;
95    let build_day = binary::read_u32(bytes, 36)?;
96    let description_strref = StrRef::from_raw(i32::from_le_bytes(
97        binary::read_u32(bytes, 40)?.to_le_bytes(),
98    ));
99    let mut reserved = [0u8; 116];
100    if let Some(slice) = bytes.get(44..44 + 116) {
101        reserved.copy_from_slice(slice);
102    }
103
104    // Some files use implicit offsets when these values are zero.
105    if keys_offset == 0 {
106        keys_offset = FILE_HEADER_SIZE;
107    }
108    if resources_offset == 0 {
109        resources_offset = keys_offset
110            .checked_add(entry_count.checked_mul(KEY_ENTRY_SIZE).ok_or(
111                ErfBinaryError::InvalidHeader("keys table size overflow".into()),
112            )?)
113            .ok_or(ErfBinaryError::InvalidHeader(
114                "resources offset overflow".into(),
115            ))?;
116    }
117
118    let mut localized_strings = Vec::new();
119    if language_count > 0 {
120        if localized_string_size < 8 {
121            return Err(ErfBinaryError::InvalidHeader(
122                "localized string block too small".into(),
123            ));
124        }
125        binary::check_slice_in_bounds(
126            bytes,
127            localized_strings_offset,
128            localized_string_size,
129            "localized string block",
130        )?;
131        let block_end = localized_strings_offset + localized_string_size;
132        let mut cursor = localized_strings_offset;
133        for index in 0..language_count {
134            if cursor.checked_add(8).is_none_or(|end| end > block_end) {
135                return Err(ErfBinaryError::InvalidData(format!(
136                    "localized string entry {index} header out of bounds"
137                )));
138            }
139            let language_id = LanguageId::from_raw(binary::read_u32(bytes, cursor)?);
140            let text_len = binary::checked_to_usize(
141                binary::read_u32(bytes, cursor + 4)?,
142                "localized_string_len",
143            )?;
144            cursor += 8;
145            if cursor
146                .checked_add(text_len)
147                .is_none_or(|end| end > block_end)
148            {
149                return Err(ErfBinaryError::InvalidData(format!(
150                    "localized string entry {index} text out of bounds"
151                )));
152            }
153            let text_bytes = bytes.get(cursor..cursor + text_len).ok_or_else(|| {
154                ErfBinaryError::InvalidData(format!(
155                    "localized string entry {index} bytes are missing"
156                ))
157            })?;
158            let text = decode_text_strict(text_bytes, ERF_TEXT_ENCODING).map_err(|source| {
159                ErfBinaryError::TextDecoding {
160                    context: format!("localized_strings[{index}]"),
161                    source,
162                }
163            })?;
164            localized_strings.push(ErfLocalizedString { language_id, text });
165            cursor += text_len;
166        }
167    }
168
169    let keys_table_size =
170        entry_count
171            .checked_mul(KEY_ENTRY_SIZE)
172            .ok_or(ErfBinaryError::InvalidHeader(
173                "keys table size overflow".into(),
174            ))?;
175    let resources_table_size =
176        entry_count
177            .checked_mul(RESOURCE_ENTRY_SIZE)
178            .ok_or(ErfBinaryError::InvalidHeader(
179                "resources table size overflow".into(),
180            ))?;
181    binary::check_slice_in_bounds(bytes, keys_offset, keys_table_size, "keys table")?;
182    binary::check_slice_in_bounds(
183        bytes,
184        resources_offset,
185        resources_table_size,
186        "resources table",
187    )?;
188
189    let mut keys = Vec::with_capacity(entry_count);
190    for key_index in 0..entry_count {
191        let base = keys_offset + key_index * KEY_ENTRY_SIZE;
192        let raw_resref = bytes
193            .get(base..base + 16)
194            .ok_or_else(|| ErfBinaryError::InvalidData("resref key bytes missing".into()))?;
195        let end = raw_resref.iter().position(|byte| *byte == 0).unwrap_or(16);
196        let resref_str =
197            decode_text_strict(&raw_resref[..end], ERF_TEXT_ENCODING).map_err(|source| {
198                ErfBinaryError::TextDecoding {
199                    context: format!("keys[{key_index}].resref"),
200                    source,
201                }
202            })?;
203        let resref = ResRef::new(&resref_str).map_err(|source| ErfBinaryError::InvalidResRef {
204            context: format!("keys[{key_index}].resref"),
205            source,
206        })?;
207        let resource_id =
208            binary::checked_to_usize(binary::read_u32(bytes, base + 16)?, "resource_id")?;
209        let resource_type_id = binary::read_u16(bytes, base + 20)?;
210        keys.push((resref, resource_id, resource_type_id));
211    }
212
213    let mut resources_meta = Vec::with_capacity(entry_count);
214    for resource_index in 0..entry_count {
215        let base = resources_offset + resource_index * RESOURCE_ENTRY_SIZE;
216        let data_offset =
217            binary::checked_to_usize(binary::read_u32(bytes, base)?, "resource_data_offset")?;
218        let data_size =
219            binary::checked_to_usize(binary::read_u32(bytes, base + 4)?, "resource_data_size")?;
220        binary::check_slice_in_bounds(
221            bytes,
222            data_offset,
223            data_size,
224            &format!("resource data[{resource_index}]"),
225        )?;
226        resources_meta.push((data_offset, data_size));
227    }
228
229    let mut resources = Vec::with_capacity(entry_count);
230    for (key_index, (resref, resource_id, resource_type_id)) in keys.into_iter().enumerate() {
231        let (data_offset, data_size) = resources_meta.get(resource_id).ok_or_else(|| {
232            ErfBinaryError::InvalidData(format!(
233                "keys[{key_index}] references missing resource id {resource_id}"
234            ))
235        })?;
236        let data = bytes
237            .get(*data_offset..(*data_offset + *data_size))
238            .ok_or_else(|| {
239                ErfBinaryError::InvalidData(format!(
240                    "resource data slice missing for keys[{key_index}]"
241                ))
242            })?
243            .to_vec();
244        resources.push(ErfResource {
245            resref,
246            resource_type: ResourceTypeCode::from_raw_id(resource_type_id),
247            data,
248        });
249    }
250
251    let erf = Erf {
252        file_type,
253        build_year,
254        build_day,
255        description_strref,
256        reserved,
257        localized_strings,
258        resources,
259    };
260    crate::trace_debug!(
261        file_type = ?erf.file_type,
262        localized_string_count = erf.localized_strings.len(),
263        resource_count = erf.resources.len(),
264        "parsed erf-family archive from bytes"
265    );
266    Ok(erf)
267}
268
269/// Reads a save archive from a reader.
270///
271/// This helper accepts both `MOD ` (canonical KotOR) and `SAV ` (compatibility)
272/// signatures on input, then normalizes the in-memory [`ErfFileType`] to
273/// [`ErfFileType::Mod`].
274#[cfg_attr(
275    feature = "tracing",
276    tracing::instrument(level = "debug", skip(reader))
277)]
278pub fn read_save_archive<R: Read>(reader: &mut R) -> Result<Erf, ErfBinaryError> {
279    let mut erf = read_erf_with_options(
280        reader,
281        ErfReadOptions {
282            input: ErfReadMode::CompatibilityAurora,
283        },
284    )?;
285    match erf.file_type {
286        ErfFileType::Mod | ErfFileType::Sav => {
287            erf.file_type = ErfFileType::Mod;
288            Ok(erf)
289        }
290        _ => Err(ErfBinaryError::InvalidData(
291            "save archive must use MOD/SAV signature".into(),
292        )),
293    }
294}
295
296/// Reads a save archive directly from bytes.
297///
298/// This helper accepts both `MOD ` and `SAV ` signatures on input and
299/// normalizes the in-memory [`ErfFileType`] to [`ErfFileType::Mod`].
300#[cfg_attr(
301    feature = "tracing",
302    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
303)]
304pub fn read_save_archive_from_bytes(bytes: &[u8]) -> Result<Erf, ErfBinaryError> {
305    let mut cursor = Cursor::new(bytes);
306    read_save_archive(&mut cursor)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::erf::{
313        write_erf_to_vec, write_erf_to_vec_with_options, write_save_archive_to_vec, ErfWriteMode,
314        ErfWriteOptions, ModLayout, MOD_BLANK_BLOCK_ENTRY_SIZE,
315    };
316    use rakata_core::{LanguageId, StrRef};
317
318    const TEST_ERF: &[u8] = include_bytes!(concat!(
319        env!("CARGO_MANIFEST_DIR"),
320        "/../../fixtures/test.erf"
321    ));
322    const TEST_MOD: &[u8] = include_bytes!(concat!(
323        env!("CARGO_MANIFEST_DIR"),
324        "/../../fixtures/capsule.mod"
325    ));
326
327    #[test]
328    fn parses_erf_fixture() {
329        let erf = read_erf_from_bytes(TEST_ERF).expect("fixture should parse");
330        assert_eq!(erf.file_type, ErfFileType::Erf);
331        assert_eq!(erf.resources.len(), 3);
332        assert_eq!(
333            erf.resource(
334                &ResRef::new("1").unwrap(),
335                ResourceTypeCode::from_raw_id(10)
336            ),
337            Some(b"abc".as_slice())
338        );
339        assert_eq!(
340            erf.resource(
341                &ResRef::new("2").unwrap(),
342                ResourceTypeCode::from_raw_id(10)
343            ),
344            Some(b"def".as_slice())
345        );
346        assert_eq!(
347            erf.resource(
348                &ResRef::new("3").unwrap(),
349                ResourceTypeCode::from_raw_id(10)
350            ),
351            Some(b"ghi".as_slice())
352        );
353    }
354
355    #[test]
356    fn parses_mod_fixture() {
357        let erf = read_erf_from_bytes(TEST_MOD).expect("fixture should parse");
358        assert_eq!(erf.file_type, ErfFileType::Mod);
359        assert_eq!(erf.resources.len(), 3);
360        assert_eq!(erf.resources[0].resref.as_str(), "001ebo");
361        assert!(!erf.resources[0].data.is_empty());
362    }
363
364    #[test]
365    fn roundtrip_synthetic_erf_with_localized_strings() {
366        let mut erf = Erf::new(ErfFileType::Erf);
367        erf.build_year = 123;
368        erf.build_day = 42;
369        erf.description_strref = StrRef::invalid();
370        erf.localized_strings.push(ErfLocalizedString {
371            language_id: LanguageId::from_raw(0),
372            text: "Test Module".into(),
373        });
374        erf.localized_strings.push(ErfLocalizedString {
375            language_id: LanguageId::from_raw(3),
376            text: "Modulo".into(),
377        });
378        erf.push_resource(
379            ResRef::new("alpha").expect("valid resref"),
380            ResourceTypeCode::from_raw_id(2017),
381            b"abc".to_vec(),
382        );
383        erf.push_resource(
384            ResRef::new("beta").expect("valid resref"),
385            ResourceTypeCode::from_raw_id(2018),
386            b"defghi".to_vec(),
387        );
388
389        let bytes = write_erf_to_vec(&erf).expect("write should succeed");
390        let parsed = read_erf_from_bytes(&bytes).expect("read should succeed");
391        assert_eq!(parsed, erf);
392    }
393
394    #[test]
395    fn roundtrip_preserves_unknown_resource_type_ids() {
396        let mut erf = Erf::new(ErfFileType::Erf);
397        erf.push_resource(
398            ResRef::new("mystery").expect("valid resref"),
399            ResourceTypeCode::from_raw_id(42424),
400            vec![1, 2, 3],
401        );
402
403        let bytes = write_erf_to_vec(&erf).expect("write should succeed");
404        let parsed = read_erf_from_bytes(&bytes).expect("read should succeed");
405        assert_eq!(parsed.resources.len(), 1);
406        assert_eq!(parsed.resources[0].resource_type.raw_id(), 42424);
407        assert_eq!(parsed.resources[0].resource_type.known_type(), None);
408    }
409
410    #[test]
411    fn read_write_roundtrip_preserves_fixture_semantics() {
412        let parsed = read_erf_from_bytes(TEST_ERF).expect("fixture should parse");
413        let bytes = write_erf_to_vec(&parsed).expect("write should succeed");
414        let reparsed = read_erf_from_bytes(&bytes).expect("re-read should succeed");
415        assert_eq!(reparsed, parsed);
416    }
417
418    #[test]
419    fn byte_exact_roundtrip_erf_fixture() {
420        let parsed = read_erf_from_bytes(TEST_ERF).expect("fixture should parse");
421        let bytes = write_erf_to_vec(&parsed).expect("write should succeed");
422        assert_eq!(
423            bytes.as_slice(),
424            TEST_ERF,
425            "ERF roundtrip is not byte-exact"
426        );
427    }
428
429    #[test]
430    fn reserved_bytes_survive_roundtrip() {
431        let mut erf = Erf::new(ErfFileType::Erf);
432        erf.reserved[0] = 0xAB;
433        erf.reserved[57] = 0xCD;
434        erf.reserved[115] = 0xEF;
435        erf.push_resource(
436            ResRef::new("a").expect("valid resref"),
437            ResourceTypeCode::from_raw_id(10),
438            b"test".to_vec(),
439        );
440
441        let bytes = write_erf_to_vec(&erf).expect("write should succeed");
442        let parsed = read_erf_from_bytes(&bytes).expect("read should succeed");
443        assert_eq!(parsed.reserved[0], 0xAB);
444        assert_eq!(parsed.reserved[57], 0xCD);
445        assert_eq!(parsed.reserved[115], 0xEF);
446    }
447
448    #[test]
449    fn writer_is_deterministic_for_parsed_fixture() {
450        let parsed = read_erf_from_bytes(TEST_ERF).expect("fixture should parse");
451        let first = write_erf_to_vec(&parsed).expect("first write should succeed");
452        let second = write_erf_to_vec(&parsed).expect("second write should succeed");
453        assert_eq!(first, second, "canonical ERF writer output drifted");
454    }
455
456    #[test]
457    fn rejects_invalid_version() {
458        let mut bytes = vec![0_u8; FILE_HEADER_SIZE];
459        bytes[0..4].copy_from_slice(b"ERF ");
460        bytes[4..8].copy_from_slice(b"V9.9");
461        let err = read_erf_from_bytes(&bytes).expect_err("must fail");
462        assert!(matches!(err, ErfBinaryError::InvalidVersion(_)));
463    }
464
465    #[test]
466    fn canonical_reader_rejects_v11_erf_version() {
467        let mut bytes = vec![0_u8; FILE_HEADER_SIZE];
468        bytes[0..4].copy_from_slice(b"ERF ");
469        bytes[4..8].copy_from_slice(b"V1.1");
470        let err = read_erf_from_bytes(&bytes).expect_err("must fail");
471        assert!(matches!(err, ErfBinaryError::InvalidVersion(_)));
472    }
473
474    #[test]
475    fn compatibility_reader_accepts_v11_erf_version() {
476        let mut bytes = vec![0_u8; FILE_HEADER_SIZE];
477        bytes[0..4].copy_from_slice(b"ERF ");
478        bytes[4..8].copy_from_slice(b"V1.1");
479        let erf = read_erf_from_bytes_with_options(
480            &bytes,
481            ErfReadOptions {
482                input: ErfReadMode::CompatibilityAurora,
483            },
484        )
485        .expect("compatibility mode should accept V1.1");
486        assert_eq!(erf.file_type, ErfFileType::Erf);
487    }
488
489    #[test]
490    fn rejects_truncated_header() {
491        let bytes = vec![0_u8; FILE_HEADER_SIZE - 1];
492        let err = read_erf_from_bytes(&bytes).expect_err("must fail");
493        assert!(matches!(err, ErfBinaryError::InvalidHeader(_)));
494    }
495
496    #[test]
497    fn rejects_out_of_range_resource_id() {
498        let mut bytes = TEST_ERF.to_vec();
499        let key_offset = usize::try_from(u32::from_le_bytes(
500            bytes[24..28].try_into().expect("key offset"),
501        ))
502        .expect("header offset fits in usize");
503        bytes[key_offset + 16..key_offset + 20].copy_from_slice(&9_u32.to_le_bytes());
504
505        let err = read_erf_from_bytes(&bytes).expect_err("must fail");
506        assert!(matches!(err, ErfBinaryError::InvalidData(_)));
507    }
508
509    #[test]
510    fn fallback_offsets_handle_zero_header_offsets() {
511        let mut bytes = TEST_ERF.to_vec();
512        bytes[24..28].copy_from_slice(&0_u32.to_le_bytes());
513        bytes[28..32].copy_from_slice(&0_u32.to_le_bytes());
514
515        let erf = read_erf_from_bytes(&bytes).expect("should parse with fallback offsets");
516        assert_eq!(erf.resources.len(), 3);
517        assert_eq!(
518            erf.resource(
519                &ResRef::new("1").unwrap(),
520                ResourceTypeCode::from_raw_id(10)
521            ),
522            Some(b"abc".as_slice())
523        );
524    }
525
526    #[test]
527    fn resref_validation_rejects_long_names() {
528        // ResRef validation happens at construction time, not write time
529        let result = ResRef::new("this_name_is_too_long");
530        assert!(result.is_err());
531    }
532
533    #[test]
534    fn canonical_writer_normalizes_sav_signature_to_mod() {
535        let mut erf = Erf::new(ErfFileType::Sav);
536        erf.description_strref = StrRef::from_raw(0);
537        erf.push_resource(
538            ResRef::new("save001").expect("valid resref"),
539            ResourceTypeCode::from_raw_id(2017),
540            vec![1, 2, 3, 4],
541        );
542
543        let bytes = write_erf_to_vec(&erf).expect("write should succeed");
544        assert_eq!(&bytes[0..4], b"MOD ");
545        let parsed = read_erf_from_bytes(&bytes).expect("canonical read should succeed");
546        assert_eq!(parsed.file_type, ErfFileType::Mod);
547    }
548
549    #[test]
550    fn writer_supports_sav_signature_in_compatibility_mode() {
551        let mut erf = Erf::new(ErfFileType::Sav);
552        erf.description_strref = StrRef::from_raw(0);
553        erf.push_resource(
554            ResRef::new("save001").expect("valid resref"),
555            ResourceTypeCode::from_raw_id(2017),
556            vec![1, 2, 3, 4],
557        );
558
559        let bytes = write_erf_to_vec_with_options(
560            &erf,
561            ErfWriteOptions {
562                output: ErfWriteMode::CompatibilityAurora,
563                ..ErfWriteOptions::default()
564            },
565        )
566        .expect("write should succeed");
567        assert_eq!(&bytes[0..4], b"SAV ");
568        let parsed = read_erf_from_bytes_with_options(
569            &bytes,
570            ErfReadOptions {
571                input: ErfReadMode::CompatibilityAurora,
572            },
573        )
574        .expect("compatibility read should succeed");
575        assert_eq!(parsed.file_type, ErfFileType::Sav);
576    }
577
578    #[test]
579    fn canonical_reader_rejects_sav_signature() {
580        let mut bytes = vec![0_u8; FILE_HEADER_SIZE];
581        bytes[0..4].copy_from_slice(b"SAV ");
582        bytes[4..8].copy_from_slice(b"V1.0");
583        let err = read_erf_from_bytes(&bytes).expect_err("canonical mode should reject SAV");
584        assert!(matches!(err, ErfBinaryError::InvalidMagic(_)));
585    }
586
587    #[test]
588    fn canonical_reader_accepts_hak_signature() {
589        let mut bytes = vec![0_u8; FILE_HEADER_SIZE];
590        bytes[0..4].copy_from_slice(b"HAK ");
591        bytes[4..8].copy_from_slice(b"V1.0");
592        let parsed = read_erf_from_bytes(&bytes).expect("canonical mode should parse HAK");
593        assert_eq!(parsed.file_type, ErfFileType::Hak);
594    }
595
596    #[test]
597    fn writer_defaults_to_tight_mod_layout() {
598        let mut mod_erf = Erf::new(ErfFileType::Mod);
599        mod_erf.push_resource(
600            ResRef::new("a").expect("valid resref"),
601            ResourceTypeCode::from_raw_id(10),
602            b"abc".to_vec(),
603        );
604        mod_erf.push_resource(
605            ResRef::new("b").expect("valid resref"),
606            ResourceTypeCode::from_raw_id(10),
607            b"def".to_vec(),
608        );
609        mod_erf.push_resource(
610            ResRef::new("c").expect("valid resref"),
611            ResourceTypeCode::from_raw_id(10),
612            b"ghi".to_vec(),
613        );
614
615        let bytes = write_erf_to_vec(&mod_erf).expect("write should succeed");
616        let entry_count = 3usize;
617        let keys_offset = usize::try_from(u32::from_le_bytes(
618            bytes[24..28].try_into().expect("keys offset"),
619        ))
620        .expect("header offset fits in usize");
621        let resources_offset = usize::try_from(u32::from_le_bytes(
622            bytes[28..32].try_into().expect("resources offset"),
623        ))
624        .expect("header offset fits in usize");
625
626        let expected_delta = KEY_ENTRY_SIZE * entry_count;
627        assert_eq!(resources_offset - keys_offset, expected_delta);
628    }
629
630    #[test]
631    fn writer_can_include_mod_blank_block_between_keys_and_resource_table() {
632        let mut mod_erf = Erf::new(ErfFileType::Mod);
633        mod_erf.push_resource(
634            ResRef::new("a").expect("valid resref"),
635            ResourceTypeCode::from_raw_id(10),
636            b"abc".to_vec(),
637        );
638        mod_erf.push_resource(
639            ResRef::new("b").expect("valid resref"),
640            ResourceTypeCode::from_raw_id(10),
641            b"def".to_vec(),
642        );
643        mod_erf.push_resource(
644            ResRef::new("c").expect("valid resref"),
645            ResourceTypeCode::from_raw_id(10),
646            b"ghi".to_vec(),
647        );
648
649        let bytes = write_erf_to_vec_with_options(
650            &mod_erf,
651            ErfWriteOptions {
652                mod_layout: ModLayout::WithBlankBlock,
653                ..ErfWriteOptions::default()
654            },
655        )
656        .expect("write should succeed");
657        let entry_count = 3usize;
658        let keys_offset = usize::try_from(u32::from_le_bytes(
659            bytes[24..28].try_into().expect("keys offset"),
660        ))
661        .expect("header offset fits in usize");
662        let resources_offset = usize::try_from(u32::from_le_bytes(
663            bytes[28..32].try_into().expect("resources offset"),
664        ))
665        .expect("header offset fits in usize");
666
667        let expected_delta =
668            (KEY_ENTRY_SIZE * entry_count) + (MOD_BLANK_BLOCK_ENTRY_SIZE * entry_count);
669        assert_eq!(resources_offset - keys_offset, expected_delta);
670
671        let blank_start = keys_offset + (KEY_ENTRY_SIZE * entry_count);
672        let blank_end = resources_offset;
673        assert!(bytes[blank_start..blank_end].iter().all(|byte| *byte == 0));
674    }
675
676    #[test]
677    fn writer_does_not_insert_blank_block_for_generic_erf() {
678        let mut erf = Erf::new(ErfFileType::Erf);
679        erf.push_resource(
680            ResRef::new("a").expect("valid resref"),
681            ResourceTypeCode::from_raw_id(10),
682            b"abc".to_vec(),
683        );
684        erf.push_resource(
685            ResRef::new("b").expect("valid resref"),
686            ResourceTypeCode::from_raw_id(10),
687            b"def".to_vec(),
688        );
689
690        let bytes = write_erf_to_vec(&erf).expect("write should succeed");
691        let entry_count = 2usize;
692        let keys_offset = usize::try_from(u32::from_le_bytes(
693            bytes[24..28].try_into().expect("keys offset"),
694        ))
695        .expect("header offset fits in usize");
696        let resources_offset = usize::try_from(u32::from_le_bytes(
697            bytes[28..32].try_into().expect("resources offset"),
698        ))
699        .expect("header offset fits in usize");
700
701        let expected_delta = KEY_ENTRY_SIZE * entry_count;
702        assert_eq!(resources_offset - keys_offset, expected_delta);
703    }
704
705    #[test]
706    fn save_helper_reads_mod_signature() {
707        let mut erf = Erf::new(ErfFileType::Mod);
708        erf.push_resource(
709            ResRef::new("save001").expect("valid resref"),
710            ResourceTypeCode::from_raw_id(2017),
711            vec![1, 2, 3, 4],
712        );
713        let bytes = write_erf_to_vec(&erf).expect("write should succeed");
714
715        let parsed = read_save_archive_from_bytes(&bytes).expect("save helper should parse MOD");
716        assert_eq!(parsed.file_type, ErfFileType::Mod);
717        assert_eq!(parsed.resources.len(), 1);
718    }
719
720    #[test]
721    fn save_helper_reads_sav_signature_in_compatibility_mode() {
722        let mut erf = Erf::new(ErfFileType::Sav);
723        erf.push_resource(
724            ResRef::new("save001").expect("valid resref"),
725            ResourceTypeCode::from_raw_id(2017),
726            vec![1, 2, 3, 4],
727        );
728        let bytes = write_erf_to_vec_with_options(
729            &erf,
730            ErfWriteOptions {
731                output: ErfWriteMode::CompatibilityAurora,
732                ..ErfWriteOptions::default()
733            },
734        )
735        .expect("write should succeed");
736        assert_eq!(&bytes[0..4], b"SAV ");
737
738        let parsed = read_save_archive_from_bytes(&bytes).expect("save helper should parse SAV");
739        assert_eq!(parsed.file_type, ErfFileType::Mod);
740        assert_eq!(parsed.resources.len(), 1);
741    }
742
743    #[test]
744    fn save_helper_writer_emits_canonical_mod_signature() {
745        let mut erf = Erf::new(ErfFileType::Sav);
746        erf.push_resource(
747            ResRef::new("save001").expect("valid resref"),
748            ResourceTypeCode::from_raw_id(2017),
749            vec![1, 2, 3, 4],
750        );
751
752        let bytes = write_save_archive_to_vec(&erf).expect("save helper write should succeed");
753        assert_eq!(&bytes[0..4], b"MOD ");
754
755        let parsed = read_erf_from_bytes(&bytes).expect("canonical read should succeed");
756        assert_eq!(parsed.file_type, ErfFileType::Mod);
757    }
758}