Skip to content

Commit aabef1f

Browse files
gudenuf
1 parent 60bf0b7 commit aabef1f

4 files changed

Lines changed: 177 additions & 98 deletions

File tree

README.md

Lines changed: 91 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,31 @@ A C# library for reading and writing Diablo 2 save files (.d2s character saves a
77

88
## Features
99

10-
- Full round-tripping support - produces identical outputs, as verified by tests.
11-
- Supports providing external .txt files for loading characters that have been saved by mods.
12-
- Full read/write support for character save files (.d2s)
13-
- Shared stash file support (.d2i)
10+
- Full read/write support for character save files (.d2s) and shared stash (.d2i)
1411
- Supports D2 LOD (version 96) and D2R (version 97+) formats
12+
- Version conversion between D2 LOD and D2R formats
1513
- Complete item parsing including stats, sockets, runewords, and set bonuses
14+
- 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
16+
- External .txt file support for modded game data
1617
- Zero external dependencies beyond .NET
1718

18-
## Acknowledgments
19-
20-
This project was vibe coded with [Claude](https://claude.ai).
21-
22-
Special thanks to:
23-
- [D2MOO](https://github.com/ThePhrozenKeep/D2MOO)
24-
- dschu012 for [d2s](https://github.com/dschu012/d2s)
25-
- Killshot
19+
## Table of Contents
20+
21+
- [Installation](#installation)
22+
- [Usage](#usage)
23+
- [Reading a Character Save](#reading-a-character-save)
24+
- [Modifying and Saving](#modifying-and-saving)
25+
- [Reading Shared Stash](#reading-shared-stash)
26+
- [Overlay API (Zero-Copy Access)](#overlay-api-zero-copy-access)
27+
- [Performance](#performance)
28+
- [Overlay Limitations](#overlay-limitations)
29+
- [Save Format Versions](#save-format-versions)
30+
- [Version Conversion](#version-conversion)
31+
- [External Data](#external-data)
32+
- [Mod Compatibility](#mod-compatibility)
33+
- [Building](#building)
34+
- [Acknowledgments](#acknowledgments)
2635

2736
## Installation
2837

@@ -74,9 +83,21 @@ int bytesWritten = save.Write(buffer);
7483
File.WriteAllBytes("MyCharacter.d2s", buffer.AsSpan(0, bytesWritten).ToArray());
7584
```
7685

77-
### Overlay API (Zero-Copy Access)
86+
### Reading Shared Stash
87+
88+
```csharp
89+
byte[] stashBytes = File.ReadAllBytes("SharedStashSoftCoreV2.d2i");
90+
D2StashSave stash = D2StashSave.Read(stashBytes);
91+
92+
foreach (var tab in stash)
93+
{
94+
Console.WriteLine($"Tab: {tab.Name}, Items: {tab.Items.Count}");
95+
}
96+
```
97+
98+
## Overlay API (Zero-Copy Access)
7899

79-
For simple modifications to the fixed-size header sections, the overlay API provides direct memory access without parsing the entire save file. This is significantly faster and allocates no memory.
100+
For simple modifications to the fixed-size header sections, the overlay API provides direct memory access without parsing the entire save file.
80101

81102
```csharp
82103
using D2SSharp.Model;
@@ -86,13 +107,13 @@ byte[] data = File.ReadAllBytes("MyCharacter.d2s");
86107
// Get a reference directly into the byte array
87108
ref var overlay = ref D2SaveLayout.From(data);
88109

89-
// Read and modify fields directly
90-
Console.WriteLine($"Name: {overlay.Character.Name}");
110+
// Read fields directly (Name is on D2SaveLayout, handles version differences)
111+
Console.WriteLine($"Name: {overlay.Name}");
91112
Console.WriteLine($"Level: {overlay.Character.Level}");
92113
Console.WriteLine($"Class: {overlay.Character.Class}");
93114

94115
// Modify character
95-
overlay.Character.Name = "NewName";
116+
overlay.Name = "NewName";
96117
overlay.Character.MercData.Experience = 1000000;
97118

98119
// Unlock all waypoints
@@ -103,15 +124,21 @@ D2SaveLayout.UpdateChecksum(data);
103124
File.WriteAllBytes("MyCharacter.d2s", data);
104125
```
105126

106-
#### Why Use the Overlay API?
127+
### Performance
128+
129+
Benchmarks comparing full parsing vs overlay access (tested on a level 99 character with full inventory):
107130

108-
| Benefit | Description |
109-
|---------|-------------|
110-
| Zero allocation | Works directly on the byte array, no object creation |
111-
| No parsing | Instant access without reading items, stats, or skills |
112-
| Simple modifications | Perfect for quick edits like name, level, flags, waypoints |
131+
| Operation | Time | Allocated |
132+
|-----------|------|-----------|
133+
| Full Deserialize | 56.1 μs | 225.8 KB |
134+
| Full Serialize | 28.4 μs | 122.5 KB |
135+
| Full Round-Trip | 86.5 μs | 348.3 KB |
136+
| **Overlay: Read Name** | **9.8 ns** | **32 B** |
137+
| **Overlay: Modify + Checksum** | **991 ns** | **2.3 KB** |
113138

114-
#### Limitations
139+
The overlay API is **~5700x faster** for reading character name and **~87x faster** for modifying fields and updating the checksum compared to a full round-trip.
140+
141+
### Overlay Limitations
115142

116143
The overlay API only covers the fixed-size header sections (first 765 bytes):
117144

@@ -125,50 +152,13 @@ The overlay API only covers the fixed-size header sections (first 765 bytes):
125152

126153
**Not accessible via overlay**: Player stats (strength, vitality, gold, etc.), skills, items, corpses, mercenary items, iron golem. These require full parsing with `D2Save.Read()`.
127154

128-
### Reading Shared Stash
129-
130-
```csharp
131-
byte[] stashBytes = File.ReadAllBytes("SharedStashSoftCoreV2.d2i");
132-
D2StashSave stash = D2StashSave.Read(stashBytes);
133-
134-
foreach (var tab in stash)
135-
{
136-
Console.WriteLine($"Tab: {tab.Name}, Items: {tab.Items.Count}");
137-
}
138-
```
139-
140-
## External Data
141-
142-
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:
143-
144-
```csharp
145-
using D2SSharp.Data;
146-
147-
// Load from a directory containing version subdirectories
148-
// Directory structure:
149-
// MyTxtFiles/
150-
// 96/
151-
// armor.txt, itemstatcost.txt, itemtypes.txt, misc.txt, weapons.txt
152-
// 99/
153-
// armor.txt, itemstatcost.txt, itemtypes.txt, misc.txt, weapons.txt
154-
var modData = new TxtFileExternalData(@"C:\path\to\MyTxtFiles");
155-
156-
// Or load from a single directory for a specific version
157-
var modData = new TxtFileExternalData(@"C:\path\to\MyTxtFiles\99", version: 99);
158-
159-
// Then use it for reading/writing
160-
var save = D2Save.Read(File.ReadAllBytes("modded.d2s"), modData);
161-
```
162-
163-
The library selects version data based on exact match with the save file's version. If the required version is not available, an exception is thrown listing the available versions.
164-
165155
## Save Format Versions
166156

167-
| Version | Game | Notes |
168-
|---------|------|-------------------------------------------|
169-
| 96 | D2 LOD 1.10+ | 32-bit item codes, 7-bit strings |
170-
| 97 | D2 Resurrected | Huffman-encoded item codes, 7-bit strings |
171-
| 98+ | D2 Resurrected | Huffman-encoded item codes, 8-bit strings |
157+
| Version | Game | Notes |
158+
|---------|------|-------|
159+
| 96 | D2 LOD 1.10+ | 32-bit item codes, 7-bit strings |
160+
| 97 | D2 Resurrected | Huffman-encoded item codes, 7-bit strings |
161+
| 98+ | D2 Resurrected | Huffman-encoded item codes, 8-bit strings |
172162

173163
## Version Conversion
174164

@@ -218,6 +208,31 @@ When comparing converted saves to saves created by the game:
218208

219209
These differences do not affect gameplay - the converted saves are fully functional.
220210

211+
## External Data
212+
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:
214+
215+
```csharp
216+
using D2SSharp.Data;
217+
218+
// Load from a directory containing version subdirectories
219+
// Directory structure:
220+
// MyTxtFiles/
221+
// 96/
222+
// armor.txt, itemstatcost.txt, itemtypes.txt, misc.txt, weapons.txt
223+
// 99/
224+
// armor.txt, itemstatcost.txt, itemtypes.txt, misc.txt, weapons.txt
225+
var modData = new TxtFileExternalData(@"C:\path\to\MyTxtFiles");
226+
227+
// Or load from a single directory for a specific version
228+
var modData = new TxtFileExternalData(@"C:\path\to\MyTxtFiles\99", version: 99);
229+
230+
// Then use it for reading/writing
231+
var save = D2Save.Read(File.ReadAllBytes("modded.d2s"), modData);
232+
```
233+
234+
The library selects version data based on exact match with the save file's version. If the required version is not available, an exception is thrown listing the available versions.
235+
221236
## Mod Compatibility
222237

223238
The library provides limited support for modded save files:
@@ -239,6 +254,16 @@ dotnet build D2SSharp.sln
239254
dotnet test
240255
```
241256

257+
## Acknowledgments
258+
259+
This project was vibe coded with [Claude](https://claude.ai).
260+
261+
Special thanks to:
262+
- [D2MOO](https://github.com/ThePhrozenKeep/D2MOO)
263+
- dschu012 for [d2s](https://github.com/dschu012/d2s)
264+
- Killshot
265+
- BRE
266+
242267
## License
243268

244269
MIT

src/D2SSharp.Tests/Benchmarks.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ public int RoundTrip()
4848
var save = D2Save.Read(_rokaBytes);
4949
return save.Write(_writeBuffer);
5050
}
51+
52+
[Benchmark]
53+
public string Overlay_ReadName()
54+
{
55+
ref var overlay = ref D2SaveLayout.From(_rokaBytes);
56+
return overlay.Name;
57+
}
58+
59+
[Benchmark]
60+
public void Overlay_ModifyAndChecksum()
61+
{
62+
// Make a copy so we don't corrupt the original
63+
var copy = _rokaBytes.ToArray();
64+
ref var overlay = ref D2SaveLayout.From(copy);
65+
overlay.Name = "TestName";
66+
overlay.Character.Level = 99;
67+
D2SaveLayout.UpdateChecksum(copy);
68+
}
5169
}
5270

5371
/// <summary>

src/D2SSharp.Tests/OverlayTests.cs

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ public void Overlay_MatchesParsedModel(string resourcePath)
3030
Assert.Equal(parsed.Version, overlay.Header.Version);
3131
Assert.Equal(parsed.FileSize, overlay.Header.FileSize);
3232
Assert.Equal(parsed.Checksum, overlay.Header.Checksum);
33-
Assert.True(overlay.Header.IsValid);
3433

35-
// Character basics
36-
Assert.Equal(parsed.Character.Name, overlay.Character.Name);
34+
// Character basics (Name is version-aware on D2SaveLayout)
35+
var expectedName = parsed.Version > 97 ? parsed.Character.Preview.Name : parsed.Character.Name;
36+
Assert.Equal(expectedName, overlay.Name);
3737
Assert.Equal(parsed.Character.Class, overlay.Character.Class);
3838
Assert.Equal(parsed.Character.Level, overlay.Character.Level);
3939
Assert.Equal(parsed.Character.Flags, overlay.Character.Flags);
@@ -73,7 +73,6 @@ public void Overlay_MatchesParsedModel(string resourcePath)
7373
Assert.Equal(parsed.Character.Preview.GuildEmblemColor, overlay.Character.Preview.GuildEmblemColor);
7474
Assert.Equal(parsed.Character.Preview.ExpansionExperience, overlay.Character.Preview.ExpansionExperience);
7575
Assert.Equal(parsed.Character.Preview.ClassicExperience, overlay.Character.Preview.ClassicExperience);
76-
Assert.Equal(parsed.Character.Preview.Name, overlay.Character.Preview.Name);
7776
Assert.Equal(parsed.Character.Preview.Unk1, overlay.Character.Preview.Unk1);
7877

7978
// Preview items
@@ -97,12 +96,10 @@ public void Overlay_MatchesParsedModel(string resourcePath)
9796
Assert.Equal(parsed.Character.SwitchRightSkill.SkillId, overlay.Character.SwitchRightSkill.SkillId);
9897

9998
// Quest section
100-
Assert.True(overlay.Quests.IsValid);
10199
Assert.Equal(parsed.Quests.Version, overlay.Quests.Version);
102100
Assert.Equal(parsed.Quests.SectionSize, overlay.Quests.SectionSize);
103101

104102
// Waypoint section
105-
Assert.True(overlay.Waypoints.IsValid);
106103
Assert.Equal(parsed.Waypoints.Version, overlay.Waypoints.Version);
107104
Assert.Equal(parsed.Waypoints.SectionSize, overlay.Waypoints.SectionSize);
108105

@@ -115,7 +112,6 @@ public void Overlay_MatchesParsedModel(string resourcePath)
115112
}
116113

117114
// Player intro section
118-
Assert.True(overlay.PlayerIntro.IsValid);
119115
Assert.Equal(parsed.PlayerIntro.SectionSize, overlay.PlayerIntro.SectionSize);
120116
}
121117

@@ -142,18 +138,29 @@ public void Overlay_ModifyAndVerify(string resourcePath)
142138
}
143139

144140
[Theory]
141+
[InlineData("Resources/96/Soska.d2s")]
145142
[InlineData("Resources/97/Amazon.d2s")]
146-
public void Overlay_ModifyNameAndVerify(string resourcePath)
143+
[InlineData("Resources/99/Soska.d2s")]
144+
public void Overlay_NameProperty(string resourcePath)
147145
{
148146
var data = File.ReadAllBytes(resourcePath);
149147

150148
ref var overlay = ref D2SaveLayout.From(data);
149+
var originalName = overlay.Name;
150+
Assert.False(string.IsNullOrEmpty(originalName));
151151

152-
overlay.Character.Name = "TestName";
152+
overlay.Name = "TestName";
153153
D2SaveLayout.UpdateChecksum(data);
154154

155+
// Verify via Name property
156+
Assert.Equal("TestName", overlay.Name);
157+
158+
// Verify parsed model agrees
155159
var parsed = D2Save.Read(data);
156-
Assert.Equal("TestName", parsed.Character.Name);
160+
if (overlay.Header.Version > 97)
161+
Assert.Equal("TestName", parsed.Character.Preview.Name);
162+
else
163+
Assert.Equal("TestName", parsed.Character.Name);
157164
Assert.True(D2Save.VerifyChecksum(data));
158165
}
159166

0 commit comments

Comments
 (0)