Skip to main content

rakata_formats/erf/
reader.rs

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