This document describes the changes required to support older Diablo 2 save file versions (pre-1.10). The analysis is based on reverse engineering the game client's save loading code.
| Version | Game Version | Key Changes |
|---|---|---|
| 97+ | D2 Resurrected | Current format (fully supported) |
| 96 | D2 LOD 1.10-1.14 | Current format (fully supported) |
| 93-95 | D2 LOD 1.09 | Uses 1.09 ItemStatCost columns, Durability override |
| 86-92 | D2 LOD 1.08 | Stat ID consolidation, complex skill encoding |
| 82-85 | D2 Classic/LOD | Legacy paired stats (Energy/Vitality) |
| <= 81 | D2 Classic | Stat bit width overrides |
Priority: Critical Affects: All stat reading/writing for version < 93
The ItemStatCost.txt file contains two sets of columns:
Save Bits/Save Add— used for version >= 931.09-Save Bits/1.09-Save Add— used for version < 93
The game selects which column to use based on version:
v163 = version < 93; // Boolean flag
// Read stat value:
saveBits = v174[v163 + 21]; // offset 21 (newer) or 22 (1.09)
saveAdd = v174[4 * v163 + 24]; // offset 24 (newer) or 28 (1.09)Only parses Save Bits and Save Add columns.
File: src/D2SSharp/Data/IExternalData.cs
Add 1.09 fields to StatInfo record:
public readonly record struct StatInfo(
byte CsvBits,
byte CsvParam,
bool CsvSigned,
int SaveBits,
int SaveParamBits,
int SaveAdd,
int SaveBits109, // NEW: 1.09-Save Bits column
int SaveAdd109, // NEW: 1.09-Save Add column
byte ValShift
)
{
public int MaxSemanticValue => (1 << SaveBits) - 1 - SaveAdd;
// NEW: Version-aware accessors
public int GetSaveBits(uint version) => version < 93 ? SaveBits109 : SaveBits;
public int GetSaveAdd(uint version) => version < 93 ? SaveAdd109 : SaveAdd;
}File: src/D2SSharp/Data/TxtFileExternalData.cs
Parse the 1.09 columns:
// In ParseItemStatCost():
int colSaveBits109 = header.GetValueOrDefault("1.09-Save Bits", -1);
int colSaveAdd109 = header.GetValueOrDefault("1.09-Save Add", -1);
// In ItemStatCostEntry struct:
public byte SaveBits109;
public int SaveAdd109;
// When creating entries:
SaveBits109 = ParseByte(GetColumn(columns, colSaveBits109)),
SaveAdd109 = ParseInt(GetColumn(columns, colSaveAdd109)),File: src/D2SSharp/Model/Item.cs
Use version-aware accessors in ReadStatList and WriteStatList:
int saveBits = statInfo.GetSaveBits(saveVersion);
int saveAdd = statInfo.GetSaveAdd(saveVersion);
int rawValue = (int)reader.ReadBits(saveBits) - saveAdd;Priority: High Affects: Armor and weapon durability reading
For version <= 95, Durability (stat 72) uses 8 bits regardless of ItemStatCost.txt value.
// At lines 1229-1232 in loading.txt:
v112 = (unsigned __int8)v111[v163 + 21]; // Get Durability SaveBits
if ( version <= 95 )
v112 = 8; // Override to 8 bitsImportant: This only affects Durability, NOT MaxDurability (stat 73).
Uses SaveBits from ItemStatCost.txt for both stats.
File: src/D2SSharp/Model/Item.cs
In armor/weapon durability reading:
// When reading Durability for version <= 95:
int durabilityBits = saveVersion <= 95 ? 8 : durabilityStat.GetSaveBits(saveVersion);In stat list reading:
if (statId == StatId.Durability && saveVersion <= 95)
saveBits = 8;Priority: High Affects: Energy and Vitality stat reading
For version <= 85, additional stats are paired:
- After stat 1 (Energy): read stat 9 (MaxMana)
- After stat 3 (Vitality): read stat 7 (MaxLife) with value shifted left by 8
// At lines 1607-1628:
case 1: // Energy
v179 = ReadInt32(ptStream, saveBits);
SetStat(1, v179 - saveAdd, 0);
if ( version <= 85 ) {
v181 = ReadInt32(ptStream, maxManaSaveBits);
SetStat(9, v181 - maxManaSaveAdd, 0); // MaxMana
}
case 3: // Vitality
v176 = ReadInt32(ptStream, saveBits);
SetStat(3, v176 - saveAdd, 0);
if ( version <= 85 ) {
v178 = ReadInt32(ptStream, maxLifeSaveBits);
SetStat(7, (v178 - maxLifeSaveAdd) << 8, 0); // MaxLife, shifted << 8
}Only pairs elemental damage stats.
File: src/D2SSharp/Model/Item.cs
// In ReadStatList, after reading primary stat:
if (saveVersion <= 85)
{
if (statId == StatId.Energy)
{
// Read MaxMana as paired stat
var maxManaInfo = externalData.GetStatInfo(StatId.MaxMana, saveVersion);
int maxManaBits = maxManaInfo.GetSaveBits(saveVersion);
int maxManaAdd = maxManaInfo.GetSaveAdd(saveVersion);
int rawMaxMana = (int)reader.ReadBits(maxManaBits) - maxManaAdd;
stats.Add(new Stat(StatId.MaxMana, 0, (long)rawMaxMana << maxManaInfo.ValShift));
}
else if (statId == StatId.Vitality)
{
// Read MaxLife as paired stat, with additional << 8 shift
var maxLifeInfo = externalData.GetStatInfo(StatId.MaxLife, saveVersion);
int maxLifeBits = maxLifeInfo.GetSaveBits(saveVersion);
int maxLifeAdd = maxLifeInfo.GetSaveAdd(saveVersion);
int rawMaxLife = (int)reader.ReadBits(maxLifeBits) - maxLifeAdd;
long maxLifeValue = ((long)rawMaxLife << maxLifeInfo.ValShift) << 8; // Extra << 8
stats.Add(new Stat(StatId.MaxLife, 0, maxLifeValue));
}
}Priority: Medium Affects: Elemental damage stats
For version <= 81, specific stats have hardcoded bit widths:
| Stat ID | Stat Name | Override Bits |
|---|---|---|
| 48 | FireMinDamage | 6 |
| 49 | FireMaxDamage | 7 |
| 51 | LightningMaxDamage | 7 |
| 55 | ColdMaxDamage | 7 |
| 57 | PoisonMinDamage | 7 |
| 58 | PoisonMaxDamage | 8 |
// At lines 1646-1728:
case 48: if (version <= 81) v185 = 6;
case 49: if (version <= 81) v188 = 7;
case 51: if (version <= 81) v192 = 7;
case 55: if (version <= 81) v199 = 7;
case 57: if (version <= 81) v203 = 7;
case 58: if (version <= 81) v206 = 8;File: src/D2SSharp/Model/Item.cs
private static int GetOverriddenSaveBits(StatId statId, uint saveVersion, int defaultBits)
{
if (saveVersion > 81)
return defaultBits;
return statId switch
{
StatId.FireMinDamage => 6,
StatId.FireMaxDamage => 7,
StatId.LightningMaxDamage => 7,
StatId.ColdMaxDamage => 7,
StatId.PoisonMinDamage => 7,
StatId.PoisonMaxDamage => 8,
_ => defaultBits
};
}Priority: Medium Affects: Class skill stats
For version <= 92, stats 83-87 and 179-180 are consolidated into stat 83 with different layer values:
| Original Stat ID | Target Stat ID | Layer |
|---|---|---|
| 83 (AddClassSkills) | 83 | 0 |
| 84 | 83 | 3 |
| 85 | 83 | 2 |
| 86 | 83 | 1 |
| 87 | 83 | 4 |
| 179 | 83 | 5 |
| 180 | 83 | 6 |
All read 3 bits for value.
// At lines 1741-1868:
case 83: if (version > 92) goto normal; else { v = ReadBits(3); SetStat(83, v, layer=0); }
case 84: if (version > 92) goto normal; else { v = ReadBits(3); SetStat(83, v, layer=3); }
// ... etcFile: src/D2SSharp/Model/Item.cs
private static bool TryReadLegacyClassSkillStat(ref BitReader reader, StatId statId,
uint saveVersion, List<Stat> stats)
{
if (saveVersion > 92)
return false;
int? layer = statId switch
{
StatId.AddClassSkills => 0,
(StatId)84 => 3,
(StatId)85 => 2,
(StatId)86 => 1,
(StatId)87 => 4,
(StatId)179 => 5,
(StatId)180 => 6,
_ => null
};
if (layer.HasValue)
{
int value = (int)reader.ReadBits(3);
stats.Add(new Stat(StatId.AddClassSkills, layer.Value, value));
return true;
}
return false;
}Priority: Medium Affects: Specific stat reading
For version <= 89, stat 134 reads 16 bits but always sets value to 1.
// At lines 1834-1847:
case 134:
if ( version > 89 ) goto normal_read;
BitStream::ReadInt32(ptStream, 16); // Read 16 bits but discard
SetStat(134, 1, 0); // Always set value to 1File: src/D2SSharp/Model/Item.cs
if (saveVersion <= 89 && (int)statId == 134)
{
reader.ReadBits(16); // Read and discard
stats.Add(new Stat(statId, 0, 1));
return true; // Skip normal processing
}Priority: Medium Affects: Specific stat reading
For version <= 92, stat 126 reads 4 bits with layer = 1.
// At lines 1824-1832:
case 126:
if ( version > 92 ) goto normal_read;
v225 = ReadInt32(ptStream, 4);
SetStat(126, v225, layer=1);File: src/D2SSharp/Model/Item.cs
if (saveVersion <= 92 && statId == (StatId)126)
{
int value = (int)reader.ReadBits(4);
stats.Add(new Stat(statId, 1, value)); // layer = 1
return true;
}Priority: Low Affects: Skill-related stats
Stats 107-109, 181-187, 188-193, 195-203, 204-213 have complex bit packing for version <= 92.
Example for stats 107-109, 181-187:
// 14 bits total: skill_id (9 bits) + level (5 bits)
v226 = ReadInt32(ptStream, 14);
skill_level = (v226 >> 9) & 0x1F;
skill_id = v226 & 0x1FF;
SetStat(107, skill_level, skill_id);Example for stats 188-193:
// 10 bits total: complex encoding
v220 = ReadInt32(ptStream, 10);
v221 = v220 & 0x1F;
v222 = (v220 >> 5) & 0x1F;
// ... complex layer calculation
SetStat(188, v223, calculated_layer);These require careful implementation of the bit packing/unpacking logic. See disassembly lines 1791-1944 for full details.
-
Phase 1: Core Infrastructure (Critical)
- Add 1.09 columns to StatInfo and parsing
- Update all stat reading to use version-aware accessors
- Add Durability 8-bit override
-
Phase 2: Version <= 85 Support (High)
- Add legacy paired stats for Energy/Vitality
-
Phase 3: Version <= 81 Support (Medium)
- Add stat bit width overrides
-
Phase 4: Version <= 92 Special Cases (Medium)
- Stat ID consolidation
- Stat 134/126 special handling
-
Phase 5: Complex Skill Encoding (Low)
- Stats 107-109, 181-213 special encoding
- Round-trip tests for each version range
- Binary comparison with saves created by the game
- Edge case testing for each version threshold
| File | Changes |
|---|---|
src/D2SSharp/Data/IExternalData.cs |
Add SaveBits109/SaveAdd109 to StatInfo |
src/D2SSharp/Data/TxtFileExternalData.cs |
Parse 1.09 columns |
src/D2SSharp/Model/Item.cs |
Version-aware stat reading/writing |
src/D2SSharp/Enums/StatId.cs |
Optional: legacy paired stats helper |
- Disassembly:
src/D2SSharp/Data/loading.txt - ItemStatCost.txt header:
1.09-Save Bits,1.09-Save Add,Save Bits,Save Add