Skip to content

Latest commit

 

History

History
471 lines (348 loc) · 11.5 KB

File metadata and controls

471 lines (348 loc) · 11.5 KB

Plan: Support Older Save File Versions

Overview

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 Thresholds

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

Required Changes

1. Dual-Column ItemStatCost System (version < 93) — CRITICAL

Priority: Critical Affects: All stat reading/writing for version < 93

Problem

The ItemStatCost.txt file contains two sets of columns:

  • Save Bits / Save Add — used for version >= 93
  • 1.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)

Current Implementation

Only parses Save Bits and Save Add columns.

Required Changes

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;

2. Durability Override (version <= 95)

Priority: High Affects: Armor and weapon durability reading

Problem

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 bits

Important: This only affects Durability, NOT MaxDurability (stat 73).

Current Implementation

Uses SaveBits from ItemStatCost.txt for both stats.

Required Changes

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;

3. Paired Stats for version <= 85

Priority: High Affects: Energy and Vitality stat reading

Problem

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
    }

Current Implementation

Only pairs elemental damage stats.

Required Changes

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));
    }
}

4. Stat Bit Width Overrides (version <= 81)

Priority: Medium Affects: Elemental damage stats

Problem

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;

Required Changes

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
    };
}

5. Stat ID Consolidation (version <= 92)

Priority: Medium Affects: Class skill stats

Problem

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); }
// ... etc

Required Changes

File: 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;
}

6. Stat 134 Special Handling (version <= 89)

Priority: Medium Affects: Specific stat reading

Problem

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 1

Required Changes

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

7. Stat 126 Special Handling (version <= 92)

Priority: Medium Affects: Specific stat reading

Problem

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);

Required Changes

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;
}

8. Complex Skill Stat Encoding (version <= 92)

Priority: Low Affects: Skill-related stats

Problem

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);

Required Changes

These require careful implementation of the bit packing/unpacking logic. See disassembly lines 1791-1944 for full details.


Implementation Order

  1. 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
  2. Phase 2: Version <= 85 Support (High)

    • Add legacy paired stats for Energy/Vitality
  3. Phase 3: Version <= 81 Support (Medium)

    • Add stat bit width overrides
  4. Phase 4: Version <= 92 Special Cases (Medium)

    • Stat ID consolidation
    • Stat 134/126 special handling
  5. Phase 5: Complex Skill Encoding (Low)

    • Stats 107-109, 181-213 special encoding

Testing Strategy

  1. Round-trip tests for each version range
  2. Binary comparison with saves created by the game
  3. Edge case testing for each version threshold

Files to Modify

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

References

  • Disassembly: src/D2SSharp/Data/loading.txt
  • ItemStatCost.txt header: 1.09-Save Bits, 1.09-Save Add, Save Bits, Save Add