halbu 0.3.0

Diablo II save file parsing library.
Documentation
use super::*;
use crate::character::v105::{MODE_CLASSIC, MODE_EXPANSION, MODE_ROTW, OFFSET_STATUS};
use crate::{Class, CompatibilityChecks, ExpansionType, GameEdition, IssueKind, Save, Strictness};

fn encode_v105_with_mode(mode_marker: u8, mercenary_hired: bool) -> Vec<u8> {
    let mut save = Save::new(FormatId::V105, Class::Barbarian);
    let expansion_type = crate::character::v105::expansion_type_from_mode_marker(mode_marker)
        .unwrap_or(ExpansionType::RotW);
    save.set_expansion_type(expansion_type);
    if mercenary_hired {
        save.character.mercenary.id = 1;
    }

    encode(&save, FormatId::V105, CompatibilityChecks::Enforce).expect("v105 save should encode")
}

#[test]
fn encodable_formats_lists_supported_versions() {
    let formats = FormatId::encodable_formats();

    assert_eq!(formats, [FormatId::V99, FormatId::V105]);
    assert_eq!(formats.map(FormatId::version), [99, 105]);
}

#[test]
fn fallback_for_unknown_version_prefers_edition_hint() {
    assert_eq!(FormatId::fallback_for_unknown_version(96, Some(GameEdition::RotW)), FormatId::V105);
    assert_eq!(
        FormatId::fallback_for_unknown_version(200, Some(GameEdition::D2RLegacy)),
        FormatId::V99
    );
}

#[test]
fn fallback_for_unknown_version_uses_closest_when_hint_missing() {
    assert_eq!(FormatId::fallback_for_unknown_version(96, None), FormatId::V99);
    assert_eq!(FormatId::fallback_for_unknown_version(104, None), FormatId::V105);
}

#[test]
fn detect_edition_hint_from_reserved_markers() {
    let mut v105_like = vec![
        0u8;
        CHARACTER_SECTION_START
            + crate::character::v105::OFFSET_RESERVED_VERSION_MARKER_TWO
            + 1
    ];
    v105_like
        [CHARACTER_SECTION_START + crate::character::v105::OFFSET_RESERVED_VERSION_MARKER_ONE] =
        0x10;
    v105_like
        [CHARACTER_SECTION_START + crate::character::v105::OFFSET_RESERVED_VERSION_MARKER_TWO] =
        0x1E;
    assert_eq!(detect_edition_hint(&v105_like), Some(GameEdition::RotW));

    let mut v99_like =
        vec![
            0u8;
            CHARACTER_SECTION_START + crate::character::v99::OFFSET_RESERVED_VERSION_MARKER_TWO + 1
        ];
    v99_like[CHARACTER_SECTION_START + crate::character::v99::OFFSET_RESERVED_VERSION_MARKER_ONE] =
        0x10;
    v99_like[CHARACTER_SECTION_START + crate::character::v99::OFFSET_RESERVED_VERSION_MARKER_TWO] =
        0x1E;
    assert_eq!(detect_edition_hint(&v99_like), Some(GameEdition::D2RLegacy));
}

#[test]
fn decode_unknown_version_prefers_v99_when_markers_match_legacy() {
    let mut bytes = include_bytes!("../../assets/test/Joe.d2s").to_vec();
    bytes[4..8].copy_from_slice(&104u32.to_le_bytes());

    let parsed = decode(&bytes, Strictness::Lax).expect("unknown-version legacy save should parse");

    assert_eq!(parsed.detected_format, FormatId::Unknown(104));
    assert_eq!(parsed.decoded_layout, FormatId::V99);
    assert_eq!(parsed.edition_hint, Some(GameEdition::D2RLegacy));
    assert_eq!(parsed.save.format(), FormatId::Unknown(104));
    assert_eq!(parsed.save.character.name, "Joe");
}

#[test]
fn decode_unknown_version_prefers_v105_when_markers_match_rotw() {
    let mut bytes = include_bytes!("../../assets/test/Warlock_v105.d2s").to_vec();
    bytes[4..8].copy_from_slice(&96u32.to_le_bytes());

    let parsed = decode(&bytes, Strictness::Lax).expect("unknown-version rotw save should parse");

    assert_eq!(parsed.detected_format, FormatId::Unknown(96));
    assert_eq!(parsed.decoded_layout, FormatId::V105);
    assert_eq!(parsed.edition_hint, Some(GameEdition::RotW));
    assert_eq!(parsed.save.format(), FormatId::Unknown(96));
    assert_eq!(parsed.save.character.class, Class::Warlock);
}

#[test]
fn encode_v105_empty_items_layout() {
    const CLASSIC_NO_ITEMS: [u8; 4] = [0x4A, 0x4D, 0x00, 0x00];
    const EXPANSION_NO_ITEMS: [u8; 13] =
        [0x4A, 0x4D, 0x00, 0x00, 0x4A, 0x4D, 0x00, 0x00, 0x6A, 0x66, 0x6B, 0x66, 0x00];
    const ROTW_NO_ITEMS: [u8; 19] = [
        0x4A, 0x4D, 0x00, 0x00, 0x4A, 0x4D, 0x00, 0x00, 0x6A, 0x66, 0x6B, 0x66, 0x00, 0x01, 0x00,
        0x6C, 0x66, 0x00, 0x00,
    ];

    let cases: [(u8, bool, &[u8]); 3] = [
        (MODE_CLASSIC, false, &CLASSIC_NO_ITEMS),
        (MODE_EXPANSION, false, &EXPANSION_NO_ITEMS),
        (MODE_ROTW, false, &ROTW_NO_ITEMS),
    ];

    for (mode_marker, mercenary_hired, expected_suffix) in cases {
        let encoded = encode_v105_with_mode(mode_marker, mercenary_hired);
        assert!(
            encoded.ends_with(expected_suffix),
            "unexpected items trailer for mode {mode_marker} (merc hired = {mercenary_hired})"
        );
    }
}

#[test]
fn encode_rejects_mercenary_hire_state_toggle_when_enforced() {
    let mut save = Save::new(FormatId::V105, Class::Barbarian);
    save.character.mercenary.id = 1;

    let error = encode(&save, FormatId::V105, CompatibilityChecks::Enforce)
        .expect_err("mercenary hire-state toggle should be rejected");
    assert!(
        error.to_string().contains("Changing mercenary.id between 0"),
        "unexpected error message: {error}"
    );

    let issues = save.check_compatibility(FormatId::V105);
    assert!(issues.iter().any(|issue| {
        issue.code == crate::CompatibilityCode::MercenaryHireStateToggleUnsupported
            && issue.blocking
    }));
}

#[test]
fn encode_allows_mercenary_hire_state_toggle_when_ignored() {
    let mut save = Save::new(FormatId::V105, Class::Barbarian);
    save.character.mercenary.id = 1;

    let encoded = encode(&save, FormatId::V105, CompatibilityChecks::Ignore)
        .expect("mercenary hire-state toggle should be force-encodable");
    assert!(!encoded.is_empty(), "forced encode should still produce bytes");
}

#[test]
fn encode_v105_preserves_legacy_expansion_status_bit() {
    let mut save = Save::new(FormatId::V105, Class::Barbarian);
    save.character.set_legacy_expansion_flag(true);

    let encoded = encode(&save, FormatId::V105, CompatibilityChecks::Enforce)
        .expect("v105 save should encode");
    let encoded_status = encoded[CHARACTER_SECTION_START + OFFSET_STATUS];

    assert_eq!(
        encoded_status & 0b0010_0000,
        0b0010_0000,
        "v105 encode should preserve legacy expansion status bit"
    );
}

#[test]
fn encode_v99_rejects_rotw_expansion_type() {
    let mut save = Save::new(FormatId::V99, Class::Barbarian);
    save.set_expansion_type(ExpansionType::RotW);

    let error = encode(&save, FormatId::V99, CompatibilityChecks::Enforce)
        .expect_err("v99 should reject RotW expansion type");
    assert!(
        error.to_string().contains("RotW expansion mode cannot be encoded to non-RotW formats"),
        "unexpected error message: {error}"
    );
}

#[test]
fn decode_v99_maps_status_bit_to_expansion_type() {
    let mut classic = include_bytes!("../../assets/test/Joe.d2s").to_vec();
    classic[CHARACTER_SECTION_START + crate::character::v99::OFFSET_STATUS] &= !0b0010_0000;
    let parsed_classic = decode(&classic, Strictness::Strict).expect("classic v99 should parse");
    assert_eq!(parsed_classic.save.expansion_type(), ExpansionType::Classic);

    let mut expansion = include_bytes!("../../assets/test/Joe.d2s").to_vec();
    expansion[CHARACTER_SECTION_START + crate::character::v99::OFFSET_STATUS] |= 0b0010_0000;
    let parsed_expansion =
        decode(&expansion, Strictness::Strict).expect("expansion v99 should parse");
    assert_eq!(parsed_expansion.save.expansion_type(), ExpansionType::Expansion);
}

#[test]
fn decode_v105_maps_mode_marker_to_expansion_type() {
    let mut classic = include_bytes!("../../assets/test/barbrotw_v105.d2s").to_vec();
    classic[CHARACTER_SECTION_START + crate::character::v105::OFFSET_MODE_MARKER] = MODE_CLASSIC;
    let parsed_classic = decode(&classic, Strictness::Strict).expect("classic v105 should parse");
    assert_eq!(parsed_classic.save.expansion_type(), ExpansionType::Classic);

    let mut expansion = include_bytes!("../../assets/test/barbrotw_v105.d2s").to_vec();
    expansion[CHARACTER_SECTION_START + crate::character::v105::OFFSET_MODE_MARKER] =
        MODE_EXPANSION;
    let parsed_expansion =
        decode(&expansion, Strictness::Strict).expect("expansion v105 should parse");
    assert_eq!(parsed_expansion.save.expansion_type(), ExpansionType::Expansion);

    let mut rotw = include_bytes!("../../assets/test/barbrotw_v105.d2s").to_vec();
    rotw[CHARACTER_SECTION_START + crate::character::v105::OFFSET_MODE_MARKER] = MODE_ROTW;
    let parsed_rotw = decode(&rotw, Strictness::Strict).expect("rotw v105 should parse");
    assert_eq!(parsed_rotw.save.expansion_type(), ExpansionType::RotW);
}

#[test]
fn summarize_extracts_core_fields_v99() {
    let bytes = include_bytes!("../../assets/test/Joe.d2s");
    let summary = Save::summarize(bytes, Strictness::Strict).expect("summary should parse");
    let parsed = decode(bytes, Strictness::Strict).expect("save should parse");

    assert_eq!(summary.version, Some(parsed.save.version()));
    assert_eq!(summary.format, Some(parsed.save.format()));
    assert_eq!(summary.edition, parsed.save.game_edition());
    assert_eq!(summary.expansion_type, Some(parsed.save.expansion_type()));
    assert_eq!(summary.name.as_deref(), Some(parsed.save.character.name.as_str()));
    assert_eq!(summary.class, Some(parsed.save.character.class));
    assert_eq!(summary.level, Some(parsed.save.character.level()));
    assert_eq!(summary.title, parsed.save.title_d2r().map(str::to_string));
    assert!(summary.issues.is_empty());
}

#[test]
fn summarize_v105_uses_mode_marker_over_status_bit_for_expansion_type() {
    let mut bytes = include_bytes!("../../assets/test/barbrotw_v105.d2s").to_vec();
    bytes[CHARACTER_SECTION_START + OFFSET_STATUS] |= 0b0010_0000;
    bytes[CHARACTER_SECTION_START + crate::character::v105::OFFSET_MODE_MARKER] = MODE_CLASSIC;

    let summary = Save::summarize(&bytes, Strictness::Strict).expect("summary should parse");
    assert_eq!(summary.expansion_type, Some(ExpansionType::Classic));
}

#[test]
fn summarize_unknown_version_uses_layout_fallback_and_reports_issue() {
    let mut bytes = include_bytes!("../../assets/test/Warlock_v105.d2s").to_vec();
    bytes[4..8].copy_from_slice(&96u32.to_le_bytes());

    let summary = Save::summarize(&bytes, Strictness::Lax).expect("summary should parse");

    assert_eq!(summary.version, Some(96));
    assert_eq!(summary.format, Some(FormatId::Unknown(96)));
    assert_eq!(summary.edition, None);
    assert_eq!(summary.class, Some(Class::Warlock));
    assert_eq!(summary.expansion_type, Some(ExpansionType::RotW));
    assert!(summary.issues.iter().any(|issue| matches!(issue.kind, IssueKind::UnsupportedVersion)));
}

#[test]
fn decode_exposes_checksum_metadata_when_header_is_present() {
    let bytes = include_bytes!("../../assets/test/Joe.d2s");
    let parsed = decode(bytes, Strictness::Strict).expect("save should parse");

    assert_eq!(parsed.detected_format, FormatId::V99);
    assert_eq!(parsed.decoded_layout, FormatId::V99);
    assert_eq!(parsed.edition_hint, None);
    assert!(parsed.header_checksum.is_some());
    assert!(parsed.computed_checksum.is_some());
    assert_eq!(parsed.header_checksum, parsed.computed_checksum);
}

#[test]
fn decode_marks_checksum_invalid_when_payload_changes_without_recompute() {
    let mut bytes = include_bytes!("../../assets/test/Joe.d2s").to_vec();
    bytes[16] ^= 0x01;

    let parsed = decode(&bytes, Strictness::Lax).expect("save should parse");
    assert_ne!(parsed.header_checksum, parsed.computed_checksum);
}

#[test]
fn decode_warns_when_unhired_mercenary_has_nonzero_fields() {
    let mut bytes = include_bytes!("../../assets/test/barbrotw_v105.d2s").to_vec();
    bytes[CHARACTER_SECTION_START + crate::character::v105::RANGE_MERCENARY.start + 6] = 20;
    bytes[CHARACTER_SECTION_START + crate::character::v105::RANGE_MERCENARY.start + 8] = 7;
    bytes[CHARACTER_SECTION_START + crate::character::v105::RANGE_MERCENARY.start + 10] = 220;

    let parsed = decode(&bytes, Strictness::Lax).expect("save should parse");
    assert!(parsed.issues.iter().any(|issue| {
        matches!(issue.kind, IssueKind::InvalidValue)
            && issue.section.as_deref() == Some("mercenary")
            && issue.message.contains("mercenary id is 0")
    }));
}

#[test]
fn summarize_warns_when_unhired_mercenary_has_nonzero_fields() {
    let mut bytes = include_bytes!("../../assets/test/barbrotw_v105.d2s").to_vec();
    bytes[CHARACTER_SECTION_START + crate::character::v105::RANGE_MERCENARY.start + 6] = 20;
    bytes[CHARACTER_SECTION_START + crate::character::v105::RANGE_MERCENARY.start + 8] = 7;
    bytes[CHARACTER_SECTION_START + crate::character::v105::RANGE_MERCENARY.start + 10] = 220;

    let summary = Save::summarize(&bytes, Strictness::Lax).expect("summary should parse");
    assert!(summary.issues.iter().any(|issue| {
        matches!(issue.kind, IssueKind::InvalidValue)
            && issue.section.as_deref() == Some("mercenary")
            && issue.message.contains("mercenary id is 0")
    }));
}