Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

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+).
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.

## Build Commands

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

### Core Components

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

- **D2StashSave** (`Model/D2StashSave.cs`): Model for shared stash files. Contains a list of D2StashTab entries.
- **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.

- **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).

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

```csharp
// Read/write using built-in embedded data (versions 96, 97, 99)
// Read/write using built-in embedded data (versions 96, 97, 99, 105)
var save = D2Save.Read(bytes);
int written = save.Write(buffer);
```
Expand All @@ -58,7 +58,7 @@ var save = D2Save.Read(bytes, modData);
int written = save.Write(buffer, modData);
```

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

The external data provides:
Expand All @@ -71,6 +71,22 @@ The external data provides:
The library uses `saveVersion` to distinguish formats:
- **Version 96**: Original D2 LOD format (32-bit item codes, 7-bit strings, 10-bit item format)
- **Version 97+**: D2R format (Huffman-encoded item codes, 8-bit strings, compact 3-bit item format, 4-field realm data)
- **Version 100+**: Advanced stash category data, chronicle data (item find tracking)
- **Version 103+**: DemonSection ("lf" magic) after IronGolem
- **Version 104+**: New header format (403 bytes), Name moved from Character to PreviewData, expanded SaveTimes/Experiences arrays, GameMode field
- **Version 105+**: Item quantity uses 1-bit presence flag for ALL items (not just stackable)

### Shared Stash Tab Format

Each stash tab has a 64-byte header: Magic(4) + StashFormat(4) + ItemFormat(4) + Gold(4) + Size(2) + Season(2) + TabType(1) + Reserved(43).

- **StashFormat < 2**: TabType is forced to Normal on read (game ignores the byte). Chronicle tabs are skipped entirely on write.
- **StashFormat >= 2**: TabType determines the tab body content:
- `Normal` (0): Items (JM section)
- `AdvancedStash` (1): Items with stackable support (JM section)
- `Chronicle` (2): Chronicle section (magic 0xC0EAEDC0) tracking found set/unique/runeword items

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.

### Item Serialization

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

## Version Conversion

The library supports converting between 1.14 (version 96) and D2R (version 97+) formats via `D2Save.Write(buffer, targetVersion)`.
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).

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

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

## Mod Compatibility

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

Expand All @@ -132,10 +148,43 @@ Each section defines a `Magic` constant used for validation. Useful for debuggin
| MercItemsSection | "jf" (0x666A) | `6A 66` |
| IronGolemSection | "kf" (0x666B) | `6B 66` |
| Item (v96 only) | "JM" (0x4D4A) | `4A 4D` |
| DemonSection (v103+) | "lf" (0x666C) | `6C 66` |
| D2StashTab header | 0xAA55AA55 | `55 AA 55 AA` |
| ChronicleSection | 0xC0EAEDC0 | `C0 ED EA C0` |

## Overlay API

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.

Two layout structs exist for different save versions:

- **`D2SaveLayout`** (765 bytes): For v<=103 saves. Character section includes 16-byte Name field and 144-byte PreviewData.
- **`D2SaveLayoutV104`** (833 bytes): For v>=104 saves. Character section removes Name (now in PreviewData) and expands PreviewData to 228 bytes (+68 bytes total).

```csharp
var data = File.ReadAllBytes("save.d2s");

// Check version first, then use the appropriate layout
uint version = BitConverter.ToUInt32(data, 4);
if (version >= 104)
{
ref var overlay = ref D2SaveLayoutV104.From(data);
overlay.Character.Level = 99;
D2SaveLayoutV104.UpdateChecksum(data);
}
else
{
ref var overlay = ref D2SaveLayout.From(data);
overlay.Character.Level = 99;
D2SaveLayout.UpdateChecksum(data);
}
```

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`.

## Testing

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.
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.

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

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

### Running Specific Test Groups
Expand Down
100 changes: 76 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ A C# library for reading and writing Diablo 2 save files (.d2s character saves a
## Features

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

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

foreach (var tab in stash)
{
Console.WriteLine($"Tab: {tab.Name}, Items: {tab.Items.Count}");
Console.WriteLine($"Tab type: {tab.TabType}, Gold: {tab.Gold}");

if (tab.TabType == StashTabType.Chronicle)
{
// Chronicle tabs track found set/unique/runeword items
Console.WriteLine($" Chronicle entries: {tab.Chronicle!.SetEntries.Count}");
}
else
{
// Normal and AdvancedStash tabs contain items
Console.WriteLine($" Items: {tab.Items.Count}");
}
}
```

## Overlay API (Zero-Copy Access)

For simple modifications to the fixed-size header sections, the overlay API provides direct memory access without parsing the entire save file.
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:

- **`D2SaveLayout`** (765 bytes) — for saves with version <= 103
- **`D2SaveLayoutV104`** (833 bytes) — for saves with version >= 104

Using the wrong layout for a version throws `InvalidDataException`.

```csharp
using D2SSharp.Model;

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

// Get a reference directly into the byte array
ref var overlay = ref D2SaveLayout.From(data);
// Check version to pick the right layout
uint version = BitConverter.ToUInt32(data, 4);
if (version >= 104)
{
ref var overlay = ref D2SaveLayoutV104.From(data);

// Name always lives in Preview for v104+
Console.WriteLine($"Name: {overlay.Name}");
Console.WriteLine($"Level: {overlay.Character.Level}");
Console.WriteLine($"GameMode: {overlay.Character.Preview.GameMode}");

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

// Modify character
overlay.Name = "NewName";
overlay.Character.MercData.Experience = 1000000;
overlay.Name = "NewName";
overlay.Waypoints.UnlockAllWaypoints();
D2SaveLayoutV104.UpdateChecksum(data);
}
else
{
ref var overlay = ref D2SaveLayout.From(data);

// Unlock all waypoints
overlay.Waypoints.UnlockAllWaypoints();
// Name is version-aware (Character.Name for v96, Preview.Name for v97+)
Console.WriteLine($"Name: {overlay.Name}");
Console.WriteLine($"Level: {overlay.Character.Level}");

overlay.Name = "NewName";
overlay.Waypoints.UnlockAllWaypoints();
D2SaveLayout.UpdateChecksum(data);
}

// Update checksum and save
D2SaveLayout.UpdateChecksum(data);
File.WriteAllBytes("MyCharacter.d2s", data);
```

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

### Overlay Limitations

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

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

## Version Conversion

The library supports converting saves between D2 LOD (1.14) and D2R formats by specifying a target version when writing:
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).

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

// Write as D2R format (version 99)
// Write as latest D2R format (version 105)
byte[] buffer = new byte[save.EstimateSize()];
int written = save.Write(buffer, targetVersion: 99);
int written = save.Write(buffer, targetVersion: 105);
File.WriteAllBytes("new_character.d2s", buffer.AsSpan(0, written).ToArray());
```

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

#### v<=103 ↔ v104+ (Header Layout)

| Field | Conversion |
|-------|------------|
| `Character.Name` | Removed in v104+; name is only in `Character.Preview.Name` |
| `PreviewData` | Expands from 144 to 228 bytes: `SaveTimes[6]`, `Experiences[6]`, `GameMode` |
| `DemonSection` | Added in v103+; initialized empty when upgrading from earlier versions |

#### v<=104 ↔ v105+ (Item Format)

| Field | Conversion |
|-------|------------|
| `Item.Quantity` | v105+ uses a 1-bit presence flag for all items, not just stackable |

#### Binary Differences

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

## External Data

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:
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:

```csharp
using D2SSharp.Data;
Expand Down
4 changes: 2 additions & 2 deletions src/D2SSharp.Tests/ConversionBinaryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,8 @@ private int CompareSections(string name1, byte[] data1, uint version1,
reader.ReadUInt32(); // checksum
positions.Add(("Header", reader.BytePosition));

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

// Quests (298 bytes)
Expand Down
Loading