Skip to content

Commit af6dcb4

Browse files
feat: Add version 100-105 format support
Add support for D2R save format versions 100-105 with restructured header, new sections, and updated item serialization. Header changes (v104+): - PreviewData expanded to 228 bytes with SaveTimes[6], Experiences[6], GameMode field, and 96-byte UTF-8 Name - Character Name field removed (now only in PreviewData) New sections: - DemonSection (v103+, "lf" magic) for summoned creature persistence - ChronicleSection (0xC0EAEDC0) tracking found set/unique/runeword items - StashTabType enum (Normal, AdvancedStash, Chronicle) for StashFormat 2 Item serialization (v105+): - Quantity uses 1-bit presence flag for ALL items, not just stackable - Chronicle recipient data (v100+) with CharacterIdentity pairs - AdvancedStashStackSize for advanced stash tab items (v100+) New enums: GameMode, DemonType, StashTabType, CharacterClass.Warlock, and stat IDs 207/361-367. Version conversion extended across v96↔v97+, v<=103↔v104+, and v<=104↔v105+ boundaries.
1 parent aecea2a commit af6dcb4

40 files changed

Lines changed: 2950 additions & 149 deletions

CLAUDE.md

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
D2SSharp is a C# library for reading and writing Diablo 2 save files (.d2s character saves and .d2i shared stash files). It supports both the original D2 LOD format (version 96) and the Diablo 2 Resurrected format (version 97+).
7+
D2SSharp is a C# library for reading and writing Diablo 2 save files (.d2s character saves and .d2i shared stash files). It supports the original D2 LOD format (version 96), Diablo 2 Resurrected format (version 97+), and the latest D2R format (version 105) with its restructured header and new item serialization.
88

99
## Build Commands
1010

@@ -26,9 +26,9 @@ dotnet test --logger "console;verbosity=detailed"
2626

2727
### Core Components
2828

29-
- **D2Save** (`Model/D2Save.cs`): Root model for character save files. Contains all sections: Character, Quests, Waypoints, Skills, Items, Corpses, MercItems, IronGolem.
29+
- **D2Save** (`Model/D2Save.cs`): Root model for character save files. Contains all sections: Character, Quests, Waypoints, Skills, Items, Corpses, MercItems, IronGolem, Demon (v103+).
3030

31-
- **D2StashSave** (`Model/D2StashSave.cs`): Model for shared stash files. Contains a list of D2StashTab entries.
31+
- **D2StashSave** (`Model/D2StashSave.cs`): Model for shared stash files. Contains a list of D2StashTab entries. Each tab has a `StashTabType` (Normal, AdvancedStash, Chronicle) that determines its body content. Chronicle tabs track found set/unique/runeword items.
3232

3333
- **Item** (`Model/Item.cs`): Complete item model with all properties. Handles both compact save format (quest items, gold, gems, runes) and complete format (equipment with stats).
3434

@@ -39,7 +39,7 @@ dotnet test --logger "console;verbosity=detailed"
3939
The library uses embedded game data tables to parse items. For standard saves, no explicit external data is needed:
4040

4141
```csharp
42-
// Read/write using built-in embedded data (versions 96, 97, 99)
42+
// Read/write using built-in embedded data (versions 96, 97, 99, 105)
4343
var save = D2Save.Read(bytes);
4444
int written = save.Write(buffer);
4545
```
@@ -58,7 +58,7 @@ var save = D2Save.Read(bytes, modData);
5858
int written = save.Write(buffer, modData);
5959
```
6060

61-
- **TxtFileExternalData.Default**: Shared default instance with embedded data for versions 96, 97, 99
61+
- **TxtFileExternalData.Default**: Shared default instance with embedded data for versions 96, 97, 99, 105
6262
- **IExternalData** (`Data/IExternalData.cs`): Interface for providing stat and item type information
6363

6464
The external data provides:
@@ -71,6 +71,22 @@ The external data provides:
7171
The library uses `saveVersion` to distinguish formats:
7272
- **Version 96**: Original D2 LOD format (32-bit item codes, 7-bit strings, 10-bit item format)
7373
- **Version 97+**: D2R format (Huffman-encoded item codes, 8-bit strings, compact 3-bit item format, 4-field realm data)
74+
- **Version 100+**: Advanced stash category data, chronicle data (item find tracking)
75+
- **Version 103+**: DemonSection ("lf" magic) after IronGolem
76+
- **Version 104+**: New header format (403 bytes), Name moved from Character to PreviewData, expanded SaveTimes/Experiences arrays, GameMode field
77+
- **Version 105+**: Item quantity uses 1-bit presence flag for ALL items (not just stackable)
78+
79+
### Shared Stash Tab Format
80+
81+
Each stash tab has a 64-byte header: Magic(4) + StashFormat(4) + ItemFormat(4) + Gold(4) + Size(2) + Season(2) + TabType(1) + Reserved(43).
82+
83+
- **StashFormat < 2**: TabType is forced to Normal on read (game ignores the byte). Chronicle tabs are skipped entirely on write.
84+
- **StashFormat >= 2**: TabType determines the tab body content:
85+
- `Normal` (0): Items (JM section)
86+
- `AdvancedStash` (1): Items with stackable support (JM section)
87+
- `Chronicle` (2): Chronicle section (magic 0xC0EAEDC0) tracking found set/unique/runeword items
88+
89+
The chronicle tab writer in the game has a size calculation bug (`add ax, 40h` at 0x140311e98) that adds 64 bytes of stale buffer data to the tab size. The reader ignores these bytes. The library preserves them in `ChronicleSection.TrailingData` for byte-exact round-trip.
7490

7591
### Item Serialization
7692

@@ -90,7 +106,7 @@ When writing: `raw_value = (semantic_value >> ValShift) + SaveAdd`
90106

91107
## Version Conversion
92108

93-
The library supports converting between 1.14 (version 96) and D2R (version 97+) formats via `D2Save.Write(buffer, targetVersion)`.
109+
The library supports converting between formats via `D2Save.Write(buffer, targetVersion)`. Handles three boundaries: v96↔v97+ (1.14↔D2R), v<=103↔v104+ (old↔new header), and v<=104↔v105+ (item quantity format).
94110

95111
Key conversion logic in `D2Save.PrepareForVersion()`:
96112

@@ -111,7 +127,7 @@ Key conversion logic in `D2Save.PrepareForVersion()`:
111127

112128
## Mod Compatibility
113129

114-
- **TrailingData**: `D2Save.TrailingData` captures any bytes after the IronGolem section. These are preserved during round-trip for mod compatibility.
130+
- **TrailingData**: `D2Save.TrailingData` captures any bytes after the last known section (IronGolem or Demon). These are preserved during round-trip for mod compatibility.
115131
- **TxtFileExternalData**: Load mod-specific .txt files for custom items/stats by providing a directory with version subdirectories.
116132
- Files with missing sections (e.g., Expansion flag set but no MercItems/IronGolem) will fail to parse.
117133

@@ -132,10 +148,43 @@ Each section defines a `Magic` constant used for validation. Useful for debuggin
132148
| MercItemsSection | "jf" (0x666A) | `6A 66` |
133149
| IronGolemSection | "kf" (0x666B) | `6B 66` |
134150
| Item (v96 only) | "JM" (0x4D4A) | `4A 4D` |
151+
| DemonSection (v103+) | "lf" (0x666C) | `6C 66` |
152+
| D2StashTab header | 0xAA55AA55 | `55 AA 55 AA` |
153+
| ChronicleSection | 0xC0EAEDC0 | `C0 ED EA C0` |
154+
155+
## Overlay API
156+
157+
The overlay API (`D2SaveOverlay.cs`) provides zero-copy, blittable struct access to the fixed-size header sections of save files via `MemoryMarshal.AsRef`. This allows direct read/write of header fields without parsing the full save.
158+
159+
Two layout structs exist for different save versions:
160+
161+
- **`D2SaveLayout`** (765 bytes): For v<=103 saves. Character section includes 16-byte Name field and 144-byte PreviewData.
162+
- **`D2SaveLayoutV104`** (833 bytes): For v>=104 saves. Character section removes Name (now in PreviewData) and expands PreviewData to 228 bytes (+68 bytes total).
163+
164+
```csharp
165+
var data = File.ReadAllBytes("save.d2s");
166+
167+
// Check version first, then use the appropriate layout
168+
uint version = BitConverter.ToUInt32(data, 4);
169+
if (version >= 104)
170+
{
171+
ref var overlay = ref D2SaveLayoutV104.From(data);
172+
overlay.Character.Level = 99;
173+
D2SaveLayoutV104.UpdateChecksum(data);
174+
}
175+
else
176+
{
177+
ref var overlay = ref D2SaveLayout.From(data);
178+
overlay.Character.Level = 99;
179+
D2SaveLayout.UpdateChecksum(data);
180+
}
181+
```
182+
183+
Both layouts provide a `Name` property that handles version-aware name access, `From()` for validation, and `UpdateChecksum()` for recalculating the checksum after modifications. Using the wrong layout for a version throws `InvalidDataException`.
135184

136185
## Testing
137186

138-
All tests use `TxtFileExternalData.Default` which provides embedded stat/item info for versions 96, 97, and 99. For modded saves, use a custom `TxtFileExternalData` instance loaded from mod-specific .txt files.
187+
All tests use `TxtFileExternalData.Default` which provides embedded stat/item info for versions 96, 97, 99, and 105. For modded saves, use a custom `TxtFileExternalData` instance loaded from mod-specific .txt files.
139188

140189
Round-trip tests verify that read -> write produces identical bytes.
141190

@@ -145,6 +194,7 @@ Resources are organized by save version:
145194
- `Resources/96/` - D2 1.14 format saves (version 96)
146195
- `Resources/97/` - D2R format saves (version 97)
147196
- `Resources/99/` - D2R 1.5+ format saves (version 99)
197+
- `Resources/105/` - D2R latest format saves (version 105) with new header and item format
148198
- `Resources/Modded/` - Modded saves with custom .txt files in `Txt/99/`
149199

150200
### Running Specific Test Groups

README.md

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ A C# library for reading and writing Diablo 2 save files (.d2s character saves a
88
## Features
99

1010
- Full read/write support for character save files (.d2s) and shared stash (.d2i)
11-
- Supports D2 LOD (version 96) and D2R (version 97+) formats
12-
- Version conversion between D2 LOD and D2R formats
11+
- Supports D2 LOD (version 96) and D2R (version 97-105) formats
12+
- Version conversion across all format boundaries (v96↔v97+, v<=103↔v104+, v<=104↔v105+)
1313
- Complete item parsing including stats, sockets, runewords, and set bonuses
14+
- Shared stash tab types: Normal, AdvancedStash (stackable items), and Chronicle (item find tracking)
15+
- DemonSection support (v103+) for summoned creature persistence
1416
- Full round-tripping support - produces identical outputs, as verified by tests
15-
- Separate [zero-copy overlay API](#overlay-api-zero-copy-access) for modifying header fields (name, level, flags, waypoints) without parsing the full save
17+
- Separate [zero-copy overlay API](#overlay-api-zero-copy-access) for modifying header fields (name, level, flags, waypoints) without parsing the full save
1618
- External .txt file support for modded game data
1719
- Zero external dependencies beyond .NET
1820

@@ -91,36 +93,67 @@ D2StashSave stash = D2StashSave.Read(stashBytes);
9193

9294
foreach (var tab in stash)
9395
{
94-
Console.WriteLine($"Tab: {tab.Name}, Items: {tab.Items.Count}");
96+
Console.WriteLine($"Tab type: {tab.TabType}, Gold: {tab.Gold}");
97+
98+
if (tab.TabType == StashTabType.Chronicle)
99+
{
100+
// Chronicle tabs track found set/unique/runeword items
101+
Console.WriteLine($" Chronicle entries: {tab.Chronicle!.SetEntries.Count}");
102+
}
103+
else
104+
{
105+
// Normal and AdvancedStash tabs contain items
106+
Console.WriteLine($" Items: {tab.Items.Count}");
107+
}
95108
}
96109
```
97110

98111
## Overlay API (Zero-Copy Access)
99112

100-
For simple modifications to the fixed-size header sections, the overlay API provides direct memory access without parsing the entire save file.
113+
For simple modifications to the fixed-size header sections, the overlay API provides direct memory access without parsing the entire save file. Two layout structs exist because v104+ saves have a different header layout:
114+
115+
- **`D2SaveLayout`** (765 bytes) — for saves with version <= 103
116+
- **`D2SaveLayoutV104`** (833 bytes) — for saves with version >= 104
117+
118+
Using the wrong layout for a version throws `InvalidDataException`.
101119

102120
```csharp
103121
using D2SSharp.Model;
104122

105123
byte[] data = File.ReadAllBytes("MyCharacter.d2s");
106124

107-
// Get a reference directly into the byte array
108-
ref var overlay = ref D2SaveLayout.From(data);
125+
// Check version to pick the right layout
126+
uint version = BitConverter.ToUInt32(data, 4);
127+
if (version >= 104)
128+
{
129+
ref var overlay = ref D2SaveLayoutV104.From(data);
130+
131+
// Name always lives in Preview for v104+
132+
Console.WriteLine($"Name: {overlay.Name}");
133+
Console.WriteLine($"Level: {overlay.Character.Level}");
134+
Console.WriteLine($"GameMode: {overlay.Character.Preview.GameMode}");
109135

110-
// Read fields directly (Name is on D2SaveLayout, handles version differences)
111-
Console.WriteLine($"Name: {overlay.Name}");
112-
Console.WriteLine($"Level: {overlay.Character.Level}");
113-
Console.WriteLine($"Class: {overlay.Character.Class}");
136+
// v104+ exposes 6 save time slots and 6 experience slots
137+
Console.WriteLine($"SaveTimes[0]: {overlay.Character.Preview.SaveTimes[0]}");
138+
Console.WriteLine($"Experiences[0]: {overlay.Character.Preview.Experiences[0]}");
114139

115-
// Modify character
116-
overlay.Name = "NewName";
117-
overlay.Character.MercData.Experience = 1000000;
140+
overlay.Name = "NewName";
141+
overlay.Waypoints.UnlockAllWaypoints();
142+
D2SaveLayoutV104.UpdateChecksum(data);
143+
}
144+
else
145+
{
146+
ref var overlay = ref D2SaveLayout.From(data);
118147

119-
// Unlock all waypoints
120-
overlay.Waypoints.UnlockAllWaypoints();
148+
// Name is version-aware (Character.Name for v96, Preview.Name for v97+)
149+
Console.WriteLine($"Name: {overlay.Name}");
150+
Console.WriteLine($"Level: {overlay.Character.Level}");
151+
152+
overlay.Name = "NewName";
153+
overlay.Waypoints.UnlockAllWaypoints();
154+
D2SaveLayout.UpdateChecksum(data);
155+
}
121156

122-
// Update checksum and save
123-
D2SaveLayout.UpdateChecksum(data);
124157
File.WriteAllBytes("MyCharacter.d2s", data);
125158
```
126159

@@ -140,12 +173,13 @@ The overlay API is **~5700x faster** for reading character name and **~87x faste
140173

141174
### Overlay Limitations
142175

143-
The overlay API only covers the fixed-size header sections (first 765 bytes):
176+
The overlay API only covers the fixed-size header sections (765 bytes for v<=103, 833 bytes for v>=104):
144177

145178
| Section | Supported Fields |
146179
|---------|-----------------|
147180
| Header | Version, FileSize, Checksum |
148181
| Character | Name, Level, Class, Flags, MercData, Hotkeys, Appearance |
182+
| Preview | PreviewItems, SaveTimes, Experiences, GameMode (v104+) |
149183
| Quests | All quest flags for all difficulties |
150184
| Waypoints | All waypoint flags for all difficulties |
151185
| PlayerIntro | NPC/Quest intro flags |
@@ -158,19 +192,23 @@ The overlay API only covers the fixed-size header sections (first 765 bytes):
158192
|---------|------|-------|
159193
| 96 | D2 LOD 1.10+ | 32-bit item codes, 7-bit strings |
160194
| 97 | D2 Resurrected | Huffman-encoded item codes, 7-bit strings |
161-
| 98+ | D2 Resurrected | Huffman-encoded item codes, 8-bit strings |
195+
| 98-99 | D2 Resurrected | Huffman-encoded item codes, 8-bit strings |
196+
| 100-102 | D2R 2.x | Advanced stash tab types, chronicle data, item find tracking |
197+
| 103 | D2R 2.x | DemonSection ("lf" magic) for summoned creature persistence |
198+
| 104 | D2R 2.x | New header layout (833 bytes), Name moved to PreviewData, GameMode field |
199+
| 105 | D2R 2.x | Item quantity uses 1-bit presence flag for all items |
162200

163201
## Version Conversion
164202

165-
The library supports converting saves between D2 LOD (1.14) and D2R formats by specifying a target version when writing:
203+
The library supports converting saves between formats by specifying a target version when writing. Conversion is handled across three boundaries: v96↔v97+ (1.14↔D2R), v<=103↔v104+ (old↔new header), and v<=104↔v105+ (item quantity format).
166204

167205
```csharp
168206
// Read a 1.14 save (version 96)
169207
var save = D2Save.Read(File.ReadAllBytes("old_character.d2s"));
170208

171-
// Write as D2R format (version 99)
209+
// Write as latest D2R format (version 105)
172210
byte[] buffer = new byte[save.EstimateSize()];
173-
int written = save.Write(buffer, targetVersion: 99);
211+
int written = save.Write(buffer, targetVersion: 105);
174212
File.WriteAllBytes("new_character.d2s", buffer.AsSpan(0, written).ToArray());
175213
```
176214

@@ -197,6 +235,20 @@ The library handles the following format differences automatically:
197235
| `Character.Preview.*` | Zeroed (1.14 doesn't use preview items) |
198236
| `Item.Position.BodyLocation` | Kept as `None` for stored items (D2R doesn't preserve original equip slot) |
199237

238+
#### v<=103 ↔ v104+ (Header Layout)
239+
240+
| Field | Conversion |
241+
|-------|------------|
242+
| `Character.Name` | Removed in v104+; name is only in `Character.Preview.Name` |
243+
| `PreviewData` | Expands from 144 to 228 bytes: `SaveTimes[6]`, `Experiences[6]`, `GameMode` |
244+
| `DemonSection` | Added in v103+; initialized empty when upgrading from earlier versions |
245+
246+
#### v<=104 ↔ v105+ (Item Format)
247+
248+
| Field | Conversion |
249+
|-------|------------|
250+
| `Item.Quantity` | v105+ uses a 1-bit presence flag for all items, not just stackable |
251+
200252
#### Binary Differences
201253

202254
When comparing converted saves to saves created by the game:
@@ -210,7 +262,7 @@ These differences do not affect gameplay - the converted saves are fully functio
210262

211263
## External Data
212264

213-
The library includes embedded game data tables for versions 96, 97, and 99, which are used automatically. For modded games with custom items/stats, you can provide your own txt files:
265+
The library includes embedded game data tables for versions 96, 97, 99, and 105, which are used automatically. For modded games with custom items/stats, you can provide your own txt files:
214266

215267
```csharp
216268
using D2SSharp.Data;

src/D2SSharp.Tests/ConversionBinaryTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ private int CompareSections(string name1, byte[] data1, uint version1,
217217
reader.ReadUInt32(); // checksum
218218
positions.Add(("Header", reader.BytePosition));
219219

220-
// Character (319 bytes)
221-
var character = Character.Read(ref reader);
220+
// Character (319 bytes for v<=103, 387 bytes for v>=104)
221+
var character = Character.Read(ref reader, version);
222222
positions.Add(("Character", reader.BytePosition));
223223

224224
// Quests (298 bytes)

0 commit comments

Comments
 (0)