Skip to content

Commit 60bf0b7

Browse files
Add overlay api
1 parent a934370 commit 60bf0b7

5 files changed

Lines changed: 884 additions & 3 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,57 @@ int bytesWritten = save.Write(buffer);
7474
File.WriteAllBytes("MyCharacter.d2s", buffer.AsSpan(0, bytesWritten).ToArray());
7575
```
7676

77+
### Overlay API (Zero-Copy Access)
78+
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.
80+
81+
```csharp
82+
using D2SSharp.Model;
83+
84+
byte[] data = File.ReadAllBytes("MyCharacter.d2s");
85+
86+
// Get a reference directly into the byte array
87+
ref var overlay = ref D2SaveLayout.From(data);
88+
89+
// Read and modify fields directly
90+
Console.WriteLine($"Name: {overlay.Character.Name}");
91+
Console.WriteLine($"Level: {overlay.Character.Level}");
92+
Console.WriteLine($"Class: {overlay.Character.Class}");
93+
94+
// Modify character
95+
overlay.Character.Name = "NewName";
96+
overlay.Character.MercData.Experience = 1000000;
97+
98+
// Unlock all waypoints
99+
overlay.Waypoints.UnlockAllWaypoints();
100+
101+
// Update checksum and save
102+
D2SaveLayout.UpdateChecksum(data);
103+
File.WriteAllBytes("MyCharacter.d2s", data);
104+
```
105+
106+
#### Why Use the Overlay API?
107+
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 |
113+
114+
#### Limitations
115+
116+
The overlay API only covers the fixed-size header sections (first 765 bytes):
117+
118+
| Section | Supported Fields |
119+
|---------|-----------------|
120+
| Header | Version, FileSize, Checksum |
121+
| Character | Name, Level, Class, Flags, MercData, Hotkeys, Appearance |
122+
| Quests | All quest flags for all difficulties |
123+
| Waypoints | All waypoint flags for all difficulties |
124+
| PlayerIntro | NPC/Quest intro flags |
125+
126+
**Not accessible via overlay**: Player stats (strength, vitality, gold, etc.), skills, items, corpses, mercenary items, iron golem. These require full parsing with `D2Save.Read()`.
127+
77128
### Reading Shared Stash
78129

79130
```csharp
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using D2SSharp.Model;
2+
using Xunit;
3+
using Xunit.Abstractions;
4+
5+
namespace D2SSharp.Tests;
6+
7+
/// <summary>
8+
/// Diagnostics to identify allocation sources.
9+
/// </summary>
10+
public class AllocationDiagnostics
11+
{
12+
private readonly ITestOutputHelper _output;
13+
14+
public AllocationDiagnostics(ITestOutputHelper output)
15+
{
16+
_output = output;
17+
}
18+
19+
[Fact]
20+
public void AnalyzeDeserializationAllocations()
21+
{
22+
// Load test file
23+
string path = Path.Combine("Resources", "99", "Roka.d2s");
24+
byte[] fileBytes = File.ReadAllBytes(path);
25+
26+
// Warm up
27+
_ = D2Save.Read(fileBytes);
28+
GC.Collect();
29+
GC.WaitForPendingFinalizers();
30+
GC.Collect();
31+
32+
// Measure total allocation
33+
long before = GC.GetAllocatedBytesForCurrentThread();
34+
var save = D2Save.Read(fileBytes);
35+
long after = GC.GetAllocatedBytesForCurrentThread();
36+
long totalBytes = after - before;
37+
38+
_output.WriteLine($"=== ALLOCATION ANALYSIS ===");
39+
_output.WriteLine($"Total allocated: {totalBytes:N0} bytes ({totalBytes / 1024.0:N1} KB)");
40+
_output.WriteLine("");
41+
42+
// Count objects
43+
int itemCount = CountItemsRecursive(save.Items);
44+
int corpseItemCount = save.Corpses.Sum(c => CountItemsRecursive(c.Items));
45+
int mercItemCount = CountItemsRecursive(save.MercItems?.Items);
46+
int golemItemCount = save.IronGolem?.GolemItem != null ? CountItemsRecursive([save.IronGolem.GolemItem]) : 0;
47+
int totalItems = itemCount + corpseItemCount + mercItemCount + golemItemCount;
48+
49+
int totalStats = save.Items.Sum(CountStatsRecursive);
50+
int playerStats = save.Stats.Count;
51+
52+
_output.WriteLine($"=== OBJECT COUNTS ===");
53+
_output.WriteLine($"Player items: {itemCount}");
54+
_output.WriteLine($"Corpse items: {corpseItemCount}");
55+
_output.WriteLine($"Merc items: {mercItemCount}");
56+
_output.WriteLine($"Golem items: {golemItemCount}");
57+
_output.WriteLine($"Total items: {totalItems}");
58+
_output.WriteLine($"Total item stats: {totalStats}");
59+
_output.WriteLine($"Player stats: {playerStats}");
60+
_output.WriteLine("");
61+
62+
// Estimate per-object costs
63+
_output.WriteLine($"=== ESTIMATED COSTS ===");
64+
_output.WriteLine($"Bytes per item (avg): {(totalItems > 0 ? totalBytes / totalItems : 0):N0}");
65+
_output.WriteLine("");
66+
67+
// Measure individual object allocations
68+
_output.WriteLine($"=== INDIVIDUAL OBJECT SIZES ===");
69+
70+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
71+
before = GC.GetAllocatedBytesForCurrentThread();
72+
var item1 = new Item();
73+
after = GC.GetAllocatedBytesForCurrentThread();
74+
_output.WriteLine($"Empty Item: {after - before} bytes");
75+
76+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
77+
before = GC.GetAllocatedBytesForCurrentThread();
78+
var item2 = new Item();
79+
_ = item2.Stats; // Force lazy init
80+
after = GC.GetAllocatedBytesForCurrentThread();
81+
_output.WriteLine($"Item + Stats list init: {after - before} bytes");
82+
83+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
84+
before = GC.GetAllocatedBytesForCurrentThread();
85+
var item3 = new Item();
86+
_ = item3.Stats;
87+
_ = item3.Sockets;
88+
_ = item3.SetBonusStats;
89+
after = GC.GetAllocatedBytesForCurrentThread();
90+
_output.WriteLine($"Item + all lists init: {after - before} bytes");
91+
92+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
93+
before = GC.GetAllocatedBytesForCurrentThread();
94+
var list1 = new List<Stat>();
95+
after = GC.GetAllocatedBytesForCurrentThread();
96+
_output.WriteLine($"Empty List<Stat>: {after - before} bytes");
97+
98+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
99+
before = GC.GetAllocatedBytesForCurrentThread();
100+
var list2 = new List<Stat>(10);
101+
after = GC.GetAllocatedBytesForCurrentThread();
102+
_output.WriteLine($"List<Stat> capacity 10: {after - before} bytes");
103+
104+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
105+
before = GC.GetAllocatedBytesForCurrentThread();
106+
var list3 = new List<Item?>();
107+
after = GC.GetAllocatedBytesForCurrentThread();
108+
_output.WriteLine($"Empty List<Item?>: {after - before} bytes");
109+
110+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
111+
before = GC.GetAllocatedBytesForCurrentThread();
112+
var qd = new RareCraftQualityData();
113+
after = GC.GetAllocatedBytesForCurrentThread();
114+
_output.WriteLine($"RareCraftQualityData: {after - before} bytes");
115+
116+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
117+
before = GC.GetAllocatedBytesForCurrentThread();
118+
var qd2 = new SetUniqueQualityData();
119+
after = GC.GetAllocatedBytesForCurrentThread();
120+
_output.WriteLine($"SetUniqueQualityData: {after - before} bytes");
121+
122+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
123+
before = GC.GetAllocatedBytesForCurrentThread();
124+
var str = new string('X', 20);
125+
after = GC.GetAllocatedBytesForCurrentThread();
126+
_output.WriteLine($"String (20 chars): {after - before} bytes (str.Length={str.Length})");
127+
128+
_output.WriteLine("");
129+
_output.WriteLine($"=== ITEM BREAKDOWN ===");
130+
131+
// Analyze what's in each item
132+
int itemsWithStats = save.Items.Count(i => i.Stats.Count > 0);
133+
int itemsWithSockets = save.Items.Count(i => i.Sockets.Count > 0);
134+
int itemsWithSetBonus = save.Items.Count(i => i.SetBonusStats.Count > 0);
135+
int itemsWithRunewordStats = save.Items.Count(i => i.RunewordStats != null);
136+
int itemsWithQualityData = save.Items.Count(i => i.QualityData != null);
137+
int itemsWithPersonalization = save.Items.Count(i => !string.IsNullOrEmpty(i.PersonalizedName));
138+
int itemsWithRealmData = save.Items.Count(i => i.RealmData != null);
139+
140+
_output.WriteLine($"Items with stats: {itemsWithStats}/{save.Items.Count}");
141+
_output.WriteLine($"Items with sockets: {itemsWithSockets}/{save.Items.Count}");
142+
_output.WriteLine($"Items with set bonus: {itemsWithSetBonus}/{save.Items.Count}");
143+
_output.WriteLine($"Items with runeword stats: {itemsWithRunewordStats}/{save.Items.Count}");
144+
_output.WriteLine($"Items with quality data: {itemsWithQualityData}/{save.Items.Count}");
145+
_output.WriteLine($"Items with personalization: {itemsWithPersonalization}/{save.Items.Count}");
146+
_output.WriteLine($"Items with realm data: {itemsWithRealmData}/{save.Items.Count}");
147+
148+
// Quality data breakdown
149+
_output.WriteLine("");
150+
_output.WriteLine($"=== QUALITY DATA TYPES ===");
151+
var qualityGroups = save.Items
152+
.Where(i => i.QualityData != null)
153+
.GroupBy(i => i.QualityData!.GetType().Name)
154+
.OrderByDescending(g => g.Count());
155+
foreach (var group in qualityGroups)
156+
{
157+
_output.WriteLine($"{group.Key}: {group.Count()}");
158+
}
159+
}
160+
161+
private static int CountItemsRecursive(IEnumerable<Item?>? items)
162+
{
163+
if (items == null) return 0;
164+
int count = 0;
165+
foreach (var item in items)
166+
{
167+
if (item == null) continue;
168+
count++;
169+
count += CountItemsRecursive(item.Sockets);
170+
}
171+
return count;
172+
}
173+
174+
private static int CountStatsRecursive(Item item)
175+
{
176+
int count = item.Stats.Count;
177+
count += item.SetBonusStats.Sum(l => l.Count);
178+
count += item.RunewordStats?.Count ?? 0;
179+
foreach (var socket in item.Sockets)
180+
{
181+
if (socket != null)
182+
count += CountStatsRecursive(socket);
183+
}
184+
return count;
185+
}
186+
}

0 commit comments

Comments
 (0)