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