A Rust library for parsing, editing, and safely re-encoding Diablo II: Resurrected .d2s save files.
It serves as the backend for Halbu Editor.
- Parse and modify
.d2ssave files - Supports the D2R Legacy and RotW layouts (
v99,v105) - Editable sections:
- character data
- attributes
- skills
- quests
- waypoints
- mercenary data
- Partial parsing via summary API
- Strict or tolerant parsing modes
- Validation for post-edit sanity checks
- Compatibility checks for format conversion
Some parts of the save format are not yet modeled:
- Items
- NPC section
These sections are preserved as raw bytes when possible, but may not round-trip identically after modifications.
cargo add halbuParse, modify, and write a save:
use halbu::{CompatibilityChecks, Save, Strictness};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let bytes = std::fs::read("Hero.d2s")?;
let parsed = Save::parse(&bytes, Strictness::Strict)?;
let mut save = parsed.save;
save.character.name = "Halbu".to_string();
save.skills.set_all(20);
let encoded = save.encode_for(save.format(), CompatibilityChecks::Enforce)?;
std::fs::write("Halbu.d2s", encoded)?;
Ok(())
}Strict parsing fails on inconsistencies:
let parsed = Save::parse(&bytes, Strictness::Strict)?;Lax parsing continues and reports issues:
let parsed = Save::parse(&bytes, Strictness::Lax)?;
if !parsed.issues.is_empty() {
eprintln!("Parse issues: {:?}", parsed.issues);
}Validation is an optional step to check the save for inconsistencies that may prevent the game from loading the save (e.g. invalid character name or mercenary level).
let report = save.validate();
if !report.is_valid() {
eprintln!("Validation issues: {:?}", report.issues);
}Compatibility checks apply when converting between formats during encoding.
use halbu::{CompatibilityChecks, Save, Strictness};
use halbu::format::FormatId;
let parsed = Save::parse(&bytes, Strictness::Strict)?;
let save = parsed.save;
let target = FormatId::V99;
let issues = save.check_compatibility(target);
if !issues.is_empty() {
eprintln!("Compatibility issues: {issues:?}");
}
// Safe (blocks on incompatibility)
let encoded = save.encode_for(target, CompatibilityChecks::Enforce)?;
// Unsafe (bypasses checks)
let forced = save.encode_for(target, CompatibilityChecks::Ignore)?;Read metadata without fully parsing the file:
let summary = Save::summarize(&bytes, Strictness::Lax)?;
println!(
"name={:?} class={:?} level={:?} expansion={:?}",
summary.name,
summary.class,
summary.level,
summary.expansion_type
);For unknown versions, Halbu can try to guess which edition the save layout matches most closely:
use halbu::format::detect_edition_hint;
use halbu::GameEdition;
let hint = detect_edition_hint(&bytes);
if hint == Some(GameEdition::RotW) {
// likely RotW (v105-style layout)
}Halbu distinguishes between three related concepts:
FormatId- concrete file format (V99,V105, or unknown)GameEdition- edition family (D2RLegacy,RotW)ExpansionType- in-game expansion mode (Classic,Expansion,RotW)
Typical usage:
save.format()-> file formatsave.game_edition()-> edition familysave.expansion_type()-> in-game expansion
- Warlock requires RotW edition and expansion
- RotW expansion cannot be encoded to non-RotW formats
- Druid and Assassin cannot be encoded as Classic
- Unknown class IDs cannot be safely converted
- Level is stored in multiple sections; use
save.set_level(...)to keep it consistent - Additional reverse-engineering notes are available in
NOTES.md
API docs: https://docs.rs/halbu
Changelog: CHANGELOG.md
These resources helped me understand the .d2s format. Many thanks to their authors.
- http://user.xmission.com/~trevin/DiabloIIv1.09_File_Format.shtml
- https://github.com/dschu012/D2SLib
- https://github.com/d07RiV/d07riv.github.io/blob/master/d2r.html
- https://github.com/oaken-source/pyd2s
- https://github.com/WalterCouto/D2CE/blob/main/d2s_File_Format.md
- https://github.com/krisives/d2s-format
- https://github.com/nokka/d2s/
- https://github.com/ThePhrozenKeep/D2MOO
- https://d2mods.info/forum/kb/index?c=4