d2s
A Rust library for reading and writing Diablo 2 save files: .d2s character saves and
.d2i shared stash files. Supports the original Lord of Destruction format (1.10+, save
version 96) and Diablo 2 Resurrected (save versions 97-105).
This crate is a Rust port of the C# library D2SSharp (NuGet). The two produce byte-identical output for the same input, and fixes are kept in sync between them.
Features
- Full read/write support for character saves (.d2s) and shared stash files (.d2i)
- D2 LOD (version 96) and D2R (versions 97-105) formats
- Version conversion across all format boundaries (v96 ↔ v97+, v<=103 ↔ v104+, v<=104 ↔ v105+)
- Complete item parsing: stats, sockets, runewords, set bonuses, ears, chronicle data
- Shared stash tab types: Normal, AdvancedStash and Chronicle
- Demon section (v103+, Reign of the Warlock)
- Exact round-tripping: reading and writing an unmodified file produces identical bytes, including unknown enum values, padding and trailing mod data
- Zero-copy overlay API for editing header fields without parsing the whole save
- Loading modded game data from .txt files
- No
unsafecode; the only dependency isbitflags(plus optionalserde)
Usage
[]
= "0.1"
Reading a character save
use ;
let bytes = read?;
let save = from_bytes?;
println!;
println!;
println!;
println!;
for item in &save.items
Modifying and saving
use ;
let mut save = from_bytes?;
save.stats.set;
save.stats.set;
save.character.level = 99;
save.skills = 20;
save.waypoints.unlock_all;
if let Some = save.items.first_mut
// File size and checksum are computed on write.
write?;
// Or reuse a buffer between writes.
let mut buf = Vecnew;
save.write_to?;
Reading a shared stash
use ;
let stash = from_bytes?;
for tab in &stash
Overlay API (zero-copy access)
For simple edits of the fixed-size header sections, the overlay API reads and writes fields
directly in the file buffer without parsing it. Layouts are generic over their storage: use
&[u8] for read-only access and &mut [u8] (or Vec<u8>) for modification.
use SaveLayout;
let mut data = read?;
let mut layout = new?; // picks the v<=103 or v104+ layout
println!;
layout.set_name?;
layout.set_level;
layout.waypoints_mut.unlock_all;
layout.update_checksum;
write?;
The version-specific layouts give access to every header field:
use ;
let mut data = read?;
if save_version? >= 104 else
The overlay covers the header, character, preview, mercenary, quest, waypoint and NPC
intro sections (765 bytes for v<=103, 833 bytes for v>=104). Stats, skills and items require
full parsing. D2StashTabLayout gives the same access to stash tab headers.
Performance
Measured with cargo bench on a level 99 character with a full inventory (tests/resources/99/Roka.d2s):
| Operation | Time |
|---|---|
| Full deserialize | 11.5 µs |
| Full serialize | 11.6 µs |
| Full round-trip | 23.3 µs |
| Overlay: read name | 8 ns |
| Overlay: modify + checksum | 0.94 µs |
Save format versions
| Version | Game | Notes |
|---|---|---|
| 96 | D2 LOD 1.10+ | 32-bit item codes, 7-bit strings |
| 97 | D2R 2.0 | Huffman-encoded item codes, 7-bit strings |
| 98-99 | D2R 2.8 | Huffman-encoded item codes, 8-bit strings |
| 100-102 | D2R 3.0 | Advanced stash tab types, chronicle data, item find tracking |
| 103 | D2R 3.0 | Demon section for summoned creature persistence |
| 104 | D2R 3.0 | New header layout (833 bytes), name moved to preview, game version |
| 105 | D2R 3.0 | Item quantity uses a 1-bit presence flag for all items |
Version conversion
convert_to changes a save to another format in place; the next write uses the new format.
use D2Save;
let mut save = from_bytes?; // version 96
save.convert_to;
write?;
Conversions handle:
- 1.14 → D2R: the name moves to the UTF-8 preview name, preview items are built from equipped items (with transforms from the appearance tints), and body locations of non-equipped items are cleared (D2R rejects them).
- D2R → 1.14: the preview name moves back to the character name and preview items are cleared.
- v<=103 ↔ v104+: the name field moves into the preview data, the preview grows to six save time / experience slots, and the expansion flag becomes the game version.
- v<=104 ↔ v105+: item quantities switch to the presence-flag format.
Stash files convert the same way with D2StashSave::convert_to, which also adjusts the stash
format (tab types require stash format 2, v100+).
External data
Parsing items requires game data: stat bit widths from ItemStatCost.txt and item
classifications from the item tables. Data for versions 96, 97, 99 and 105 is embedded
(feature embedded-data, on by default) and parsed lazily per version. For modded games,
load your own .txt files:
use ;
// A directory with version subdirectories: MyTxtFiles/99/{armor,itemstatcost,itemtypes,misc,weapons}.txt
let data = from_dir?;
// Or a single directory for one version.
let data = from_version_dir?;
let save = from_bytes_with?;
let bytes = save.to_bytes_with?;
Data is selected by exact save version; reading a version without data fails with
Error::UnsupportedVersion. You can also implement the ExternalData trait yourself.
Disable default features to drop the embedded tables (~1.2 MB) if you always supply your own.
Cargo features
| Feature | Default | Description |
|---|---|---|
embedded-data |
yes | Embedded game tables and the from_bytes/to_bytes conveniences |
serde |
no | Serialize/Deserialize for all model types |
Mod compatibility
- Custom item data: load mod .txt files as shown above.
- Trailing data: bytes after the last known section are kept in
D2Save::trailing_dataand written back unchanged. - Saves must contain all standard sections; modified section layouts are not supported.
Building
Related projects
- D2SSharp: the C# library this crate is ported from.
Acknowledgments
License
MIT