Diablo II's item description engine, reimplemented from the disassembly — for 1.14d and for Diablo II: Resurrected (both its base tables and Reign of the Warlock). Hand it a unit and it renders the tooltip the game would draw for it — the same text, the same order, the same colours.
Available for C# (D2ItemToolkit) and TypeScript (d2itemtoolkit). The game's tables are
embedded in both packages, so there is nothing to download or point at.
Vigorous Large Shield of Absorption
Defense: 300
Chance to Block: 30%
Smite Damage: 2 to 4
Durability: 40 of 62
Required Strength: 34
Required Level: 24
+150% Enhanced Defense
Fire Resist +25%
30% Better Chance of Getting Magic Items
That is the quick start below, with the game's colour markers stripped for readability — see Render for what the raw string contains.
Useful for trade sites, loot filters, stash viewers, bots, drop notifiers — anywhere you have item data and need to show it the way a player expects to see it.
Pre-1.0. The public surface may still move between minor versions.
Written by Claude, without human supervision. Every line of both implementations, the tests, and the docs were produced by an AI agent working from the 1.14d and D2R disassemblies. Nobody has read it line by line. What holds it together is mechanical rather than editorial: a differential test that requires the two implementations to agree byte for byte across ~940 generated 1.14d cases, ~1,100 D2R cases and ~12,000 hostile ones, plus captured game output. So treat the behaviour as tested and the source as unreviewed — it is dense, it comments oddities at the address that causes them, and it will not read like code a human wrote for other humans.
dotnet add package D2ItemToolkit # netstandard2.0
npm install d2itemtoolkit # ESM only, Node 18+ and browsersThe MIT licence covers the source. Both packages also embed Diablo II's shipped data tables, which are Blizzard's property and are not covered by it — see Licence.
Two inputs: the item, and optionally the viewer — the player looking at it. The viewer
decides requirement colours, class gates, attack speed, block chance, smite damage and every
level-scaled line. Pass null and you get the item rendered with no player context, which is a
legal call that produces fewer lines.
Both are unit documents of the same shape, because an item and a player are the same struct in the game. A socket filler is another one nested inside — and so is a player's carried gear.
using D2ItemToolkit;
Unit item = Unit.FromJson(itemJson);
Unit player = Unit.FromJson(playerJson);
Tooltip tip = TooltipEngine.Embedded.Render(item, player);
Console.WriteLine(tip.Text);import { TooltipEngine, unitFromJson } from 'd2itemtoolkit';
const item = unitFromJson(itemJson);
const player = unitFromJson(playerJson);
const tip = TooltipEngine.embedded.render(item, player);
console.log(tip.text);Same call chain, camelCased. TooltipEngine.Embedded / .embedded parses the tables once and
caches them — hold onto it rather than building an engine per item. Rendering is read-only and safe
to share between threads.
The tables are inflated synchronously, which is what keeps the whole API non-async.
One engine per game. TooltipEngine.Embedded is 1.14d, as it always was; ForVariant picks
another, built once and cached like Embedded.
GameVariant |
the game | tables |
|---|---|---|
Lod114d |
Diablo II: Lord of Destruction 1.14d | 1.14d MPQ |
Resurrected |
Diablo II: Resurrected, base game (item version 1 or 2) | excel/base |
ReignOfTheWarlock |
Diablo II: Resurrected — Reign of the Warlock (item version 3) | excel |
Tooltip tip = TooltipEngine.ForVariant(GameVariant.ReignOfTheWarlock).Render(item, player);
// The strings legacy graphics mode shows, e.g. "Quantity: 32" rather than "Quantity: 32 of 500".
TooltipEngine legacy = TooltipEngine.ForVariant(
GameVariant.ReignOfTheWarlock, new ResurrectedTextOptions { LegacyGraphics = true });
// Any of D2R's thirteen locales, with its name grammar: "gezacktes Kurzschwert der Dornen".
TooltipEngine german = TooltipEngine.ForVariant(
GameVariant.ReignOfTheWarlock, new ResurrectedTextOptions { Language = "deDE" });ForVariant with options builds a new engine each call — keep it.
const tip = TooltipEngine.forVariant(GameVariant.ReignOfTheWarlock).render(item, player);Pick the variant that matches the item. The record format is the same, but the ids in it are indexes into that game's tables, and the tables moved: Reign of the Warlock added 38 magic suffixes ahead of the prefixes, so every magic prefix id differs by 38 from 1.14d. A RotW item rendered with 1.14d tables names the wrong prefix and takes that prefix's level requirement.
What D2R changes on screen, all traced and implemented: the line text comes from printf templates
(%+d and friends), one-affix magic names lose their stray space, durability no longer marks an
enhanced maximum, quantities read "Quantity: 32 of 500", belts show "Belt Size: +4 Slots", runes and
event items get their own name colours, the Horadric Cube's usage line moves into the spell
description, the +3 cap on your own class's oskills is gone, the Warlock takes its place in every
class table, and there is no 1023-character cut. docs/resurrected.md in the repository has the
full list with addresses.
You do not need JSON. Everything required to author a record is public.
var item = new Unit();
item.UnitType = 4;
item.ClassId = 330; // Large Shield
item.Quality = ItemQualityNo.Magic;
item.ItemFlags = ItemRecordFlags.Identified;
// 1-based indices into the CONCATENATED [magicsuffix][magicprefix][automagic] array, so 962 is
// past the 747 suffix rows and lands in the prefix table.
item.MagicPrefix[0] = 962;
item.MagicSuffix[0] = 121;
item.StatsLists.Add(
new UnitStatList(0, ItemStatListFlags.Extended) // the base array
.Add(31, 120).Add(72, 40).Add(73, 62));
item.StatsLists.Add(
new UnitStatList(0, ItemStatListFlags.Magic) // what the item itself grants
.Add(16, 150).Add(39, 25).Add(80, 30));
Tooltip tip = TooltipEngine.Embedded.Render(item);In TypeScript use createUnit, which defaults every field you do not set. There is no fluent
statlist builder — write them as object literals:
const item = createUnit({
unitType: 4,
classId: 330,
quality: 4,
itemFlags: 16,
magicPrefix: [962, 0, 0],
magicSuffix: [121, 0, 0],
statsLists: [
{ stateNo: 0, flags: 0x80000000, stats: [{ id: 31, value: 120 }, { id: 72, value: 40 }] },
{ stateNo: 0, flags: 0x40, stats: [{ id: 16, value: 150 }, { id: 39, value: 25 }] },
],
});IUnit is the contract; Unit is one implementation of it. Every C# entry point takes the
interface, so if you already hold unit state in your own shape — a live client, another DTO, a
database row — implement IUnit over it instead of copying. In TypeScript Unit is an interface,
but every one of its fields is required, so createUnit is the practical way in.
IUnit (item) ──────┐
├── TooltipEngine ──┬── Render → the tooltip
IUnit (viewer) ────┘ ├── Breakdown → where each modifier came from
optional ├── Ranges → what each stat could have rolled
├── RangesForViewer → the same, plus the viewer's set tiers
├── MergedStats → what the stats add up to
├── SocketFillerStats → what one gem or rune grants its host
├── Damage → the weapon damage numbers
├── Appearance → inventory sprite and palette shift
├── Requirements → strength / dexterity / level / class
└── ClassIdsOfType → every classId under a type code
It also exposes the parsed game tables — see Reading the tables. The section writers and the description engine stay internal.
Tooltip.Lines comes back in display order, top row first.
| member | what it gives you |
|---|---|
tip.Kind |
which builder produced this — Generic, Book or IdentifiedSetItem |
tip.Lines |
one entry per row, each with Text, Color and originating Section |
tip.Text |
the rows joined with newlines |
tip.ColoredText |
the same, plus the game's per-line U+00FF 'c' N colour marker |
ItemTooltipKind also declares ShopTransaction and Transmogrify. Nothing currently produces
them — they belong with the transaction-cost gap in what is not
implemented.
Neither string form is marker-free. Some section writers embed a marker mid-line, and the game
embeds those too, so they survive in Text. The same shield as above:
Vigorous Large Shield of Absorption
Defense: ÿc3300
ÿc0Chance to Block: ÿc330%
Smite Damage: 2 to 4
...
Two markers on the block line is not a bug: INV_FormatBlockChanceText prepends colour 0 to its
own label buffer and LoadItemDesc prepends the section's on top. If you want plain text, strip
them — Regex.Replace(tip.Text, "ÿc.", "") in C#, tip.text.replace(/ÿc./g, '') in
TypeScript. Prefer Lines if you are rendering yourself: you get the colour per row without
parsing markers out of a string.
Do not join
line.Textyourself. Both string forms spend the game's 1023-character budget across the rows before joining, so a long tooltip truncates where the game truncates. (Set-item tooltips are exempt — that path has no limit — and so is every D2R tooltip.) Eachline.Textalso ends with its own\n.
In TypeScript line.text is typed string | null, so narrow it before use.
Every knob on TooltipOptions. The defaults reproduce the game exactly, with one documented
exception; the three marked beyond the game deliberately do not, and
none of them changes the output unless you set it.
| option | default | what it does |
|---|---|---|
Difficulty |
0 |
GetDificulity(). Only a quest item with questdiffcheck reads it |
DesecratedZonesEnabled |
false |
D2R only. Whether the game has desecrated (terror) zones on. The Worldstone Shards' usage condition reads it with Difficulty; when the condition fails their name is red |
ShopMode |
0 |
1.14d only. 0 outside a shop. Any non-zero value suppresses both usage lines; 1–9 also admit the transaction-cost line, which only the set-item path fills today (see the gap below) |
ClientPlayer |
null |
The character, when the viewer is a mercenary — see below |
Sockets |
Merged |
Excluded and Separated go beyond the game. What the render does with the socket fillers |
Ranges |
null |
Beyond the game. Non-null writes each stat's roll span inline. Format chooses the wording, Color the colour (grey by default, so a span reads as an annotation rather than as part of the line; -1 inherits the line's) |
ShowItemLevel |
false |
Beyond the game. Appends [ilvl 67] after the item's name, in the same grey a span uses. Silently absent when the record carries no level (-1) |
Sockets is one value rather than several booleans because the alternatives are mutually
exclusive: Merged is what the game draws, Excluded renders the item as if nothing were socketed
in it, and Separated moves each filler's mods into its own block below the item.
var options = new TooltipOptions();
options.ShopMode = 1; // 1-9 mark a shop context (see the gap below)
options.Sockets = SocketMode.Separated; // one block per gem, below the item
options.Ranges = new RangeDisplay(); // "+175% Enhanced Damage [150-200]"
options.Ranges.Color = ItemTooltipColor.White; // override the grey default
options.ClientPlayer = character; // see belowClientPlayer exists for exactly one case: a mercenary's panel. Requirements, class
restriction, block chance and the smite gate all use the viewer — who is the merc — but the attack
speed line is timed against the character. If you are rendering a merc's equipment, set this to
the player. Leave it null everywhere else.
The TypeScript names are the same in camelCase, and it is a plain object literal, so you only pass
what you set: { sockets: 'separated', ranges: { color: 0 } }.
The game merges a filler's mods into the item's own block, so you cannot tell which gem did what.
Sockets = Separated moves them out — the item shows only its own, and each filler gets a
block below headed by its name:
merged (what the game draws) Sockets = Separated
──────────────────────────── ───────────────────────────
Gemmed Crystal Sword Crystal Sword
One-Hand Damage: 6 to 18 One-Hand Damage: 5 to 15
Durability: 20 of 20 Durability: 20 of 20
+20% Enhanced Damage Socketed (3)
+40 to Attack Rating
Adds 20-50 fire damage Ral Rune
Socketed (3) Adds 5-30 fire damage
Perfect Ruby
Adds 15-20 fire damage
Jagged Jewel
+20% Enhanced Damage [10-20]
+40 to Attack Rating
The blocks carry ItemTooltipSection.SocketContribution and are separated by a blank row, so three
gems do not read as one list. Nothing is dropped — the fillers are moved, which is why the item's
own damage line drops back to its unsocketed value.
Combined with a non-null Ranges, a jewel is ranged from its own affixes, which is the case that
actually rolls. A gem or rune shows no span, and that is correct rather than missing: no gems.txt
cell rolls at all. The three whose min differs from their max — dmg-fire, dmg-ltng and
dmg-cold on Ral, Ort and Thul — are the two fixed ends of a damage range, not a roll.
The game draws one blue block. Breakdown splits it by source, which is what a "hold shift" view
wants:
TooltipBreakdown b = TooltipEngine.Embedded.Breakdown(item, player);For the shield above with a Perfect Ruby socketed:
b.Base |
+120 Defense |
b.Magic |
+150% Enhanced Defense, Fire Resist +25%, 30% Better Chance of Getting Magic Items |
b.Sockets |
Fire Resist +40% |
b.SetBonuses |
(none) |
Render merges those into one Fire Resist +65%, because that is what the game does.
Sockets = SocketMode.Excluded gives Fire Resist +25% instead.
Breakdown takes the same TooltipOptions, so a non-null Ranges annotates each bucket with the
span that matches ITS numbers — the item's own for Base, Magic and SetBonuses, the fillers'
for Sockets. See Ranges.
Caveat: the game never draws these separately, so unlike Render this cannot be checked
against the original — it is the one part of the API with no ground truth. Every line is still
produced by the same writers; only the stat selection differs.
To show +120 Defense (98–141) you need the span the roll came from. Ranges rebuilds the
properties the item's own sources would have rolled and reports each stat's low and high:
ItemRollRanges ranges = TooltipEngine.Embedded.Ranges(item);
foreach (RolledStatRange r in ranges.Stats)
{
if (r.IsRange)
{
Console.WriteLine($"stat {r.StatId}: {r.Low}..{r.High} from {r.Sources}");
}
}Every rendered line also tells you which stat it came from — line.StatId and line.Layer, or -1
for a line that shows no stat — so you can pair the two yourself and decide whether a range becomes
(98–141), a bar, or a colour.
Some lines speak for more than one stat: Adds 1-4 fire damage is firemindam and firemaxdam
together, +2 to All Attributes stands for four. Those carry line.Aggregated and
line.ShownStats — every stat the line shows a number for, in the order the numbers appear.
ShownStats is null when StatId is the whole story, so the two cases can be handled uniformly:
int[] stats = line.ShownStats ?? new[] { line.StatId };Range annotation is written into line.Text as each line is built, so the annotated output
survives rendering from Lines — you do not have to go through Text or ColoredText to get it.
A span always matches the number beside it. That is the rule the three views follow, and it is why they disagree. Take a rare armour rolling Fire Resist 11–20, socketed with a jewel rolling Fire Resist 5–10:
| view | line | span | why |
|---|---|---|---|
Render (default) |
Fire Resist +28% |
[16-30] |
one line holding the SUM, so the span sums both: 11+5 to 20+10 |
Render + Sockets = Separated |
Fire Resist +20% |
[11-20] |
the fillers are moved out, so the line and its span are the item's own |
| " " (the jewel's block) | Fire Resist +8% |
[5-10] |
the jewel's own affix roll |
Breakdown.Magic |
Fire Resist +20% |
[11-20] |
the item's own mods, sockets excluded |
Breakdown.Sockets |
Fire Resist +8% |
[5-10] |
what the fillers add |
Ranges work in both Render and Breakdown — pass the same Ranges either way.
If you just want it written inline, set the flag:
var options = new TooltipOptions { Ranges = new RangeDisplay() };
foreach (ItemTooltipLine line in TooltipEngine.Embedded.Render(item, player, options).Lines)
{
Console.WriteLine(line.Text);
}The Eye of Etlich Superior Crystal Sword
Amulet One-Hand Damage: 5 to 16
Required Level: 15 Durability: 20 of 22
+1 to All Skills Required Strength: 43
Adds 1-4 cold damage +7% Enhanced Damage [0-15]
5% Life stolen per hit [3-7] +2 to Attack Rating [1-3]
+25 Defense vs. Missile [10-40] Increase Maximum Durability 12% [10-15]
+3 to Light Radius [1-5]
A line that prints two numbers gets two spans, positionally:
Adds 1-4 cold damage [(1-2)-(3-5)] the first number rolled 1-2, the second 3-5
+175% Enhanced Damage [150-200] one number, so one span
+2 to all Attributes four stats sharing one number, all fixed — nothing to say
Ranges.Color paints the spans distinctly. The annotation is wrapped in a colour marker and a second
one restoring the line's own, so nothing after it is repainted and the following line is unaffected.
The format is yours — Ranges.Format receives one RolledStatRange per STAT the line shows, in
print order, and returning null or an empty string suppresses the annotation, which is how you show
ranges for some stats and not others. Stats shown, not numbers printed: the +2 to all Attributes
line above prints one number and hands the callback four ranges, which is why the built-in format
has a rule for collapsing them.
Three things it deliberately leaves alone:
| left alone | why |
|---|---|
+2 to All Skills when it could only be 2 |
a degenerate range reads as a range |
Required Level: 15 |
not a stat |
| a partly-resolved multi-number line | one span against two numbers belongs to neither |
Packed values are decoded, not skipped. A charged-skill stat stores
(maxCharges << 8) + current, so its raw span reads [2306-2313]; DisplayLow/DisplayHigh give
the charge count instead, and the line comes out Level 13 Cloak of Shadows (5/9 Charges) [2-9].
IsPackedEncoding tells you when the raw ends are an encoding rather than a magnitude. The by-time
stats are packed too but never roll — the property's own min and max go in verbatim — so there is
nothing to show for them either way.
Sources are resolved from the record alone: the affix ids it stores, its UniqueItems or SetItems
row, its runeword name, its superior modifier, its socket fillers, and the base Defense roll. Set
bonuses are excluded by default because they belong to the worn set rather than to the item; pass
earnedSetIds to fold them in.
Three cases return something other than a plain span, and each says so rather than guessing:
LayerVaries |
the roll picked the skill, not the value — Ormus' Robes is always +3, to one of 25 sorceress skills. A recorded stat inside that span is explained by it, so it is not reported Unattributed |
Choices |
D2R only: the roll picked which properties apply (PropertyGroups.txt — Wraithstep's skill tab, Opalvein's element, the crafted charms). Each choice lists its options and whether the record resolves, contradicts or leaves it ambiguous |
CraftedRecipeUnknown |
a crafted item's record does not name the cube recipe that made it, and this one could not be worked out, so the recipe's fixed mods stay unattributed |
ItemLevelDependent |
a few properties derive their value from the item's level. Supply itemLevel on the record and they become exact |
OutOfRange lists stats whose recorded value falls outside the span computed for them. For a record
the game produced it is empty; if it is not, the reconstruction is wrong and says so.
A crafted item's record does not say which cube recipe made it, but usually that can be worked out.
The 36 crafted recipes are four families over nine equipment slots, one per pair, so the item's slot
leaves four candidates, and the one whose every fixed mod the record actually carries is the answer.
CraftedRecipe gives the resulting cubemain.txt row, and the recipe's mods then carry real spans
instead of sitting in Unattributed:
TooltipEngine engine = TooltipEngine.Embedded;
ItemRollRanges ranges = engine.Ranges(craftedCrown);
if (ranges.CraftedRecipe >= 0)
{
// e.g. "magic crown + jewel + rune 06 + perfect emerald -> safety helm"
Console.WriteLine(engine.Data.CubeMain.GetString(ranges.CraftedRecipe, "description"));
}It declines rather than guesses. Two families can both fit when the item's own affixes happen to
supply the other's stats; a base under none of the nine crafted slots — a charm or a quiver, say —
reaches no recipe at all; and a class-specific shield (a paladin auric shield, a necromancer voodoo
head) resolves to no slot either, because those sit beside the ordinary shield type rather than
under it. Each leaves CraftedRecipe at -1 and
CraftedRecipeUnknown true. On the shipped tables it can fail to name a recipe, but it does not
name the wrong one.
Caveat: as with Breakdown, the game never computes this, so it has no ground truth to be
checked against. What it is checked against is the tables' own min/max columns, the item's own
recorded values, and the other implementation over every corpus case.
Render answers "what does the game draw". MergedStats answers "what does this item give", which
is the question a stored item has to answer — and the raw statlists cannot, for three reasons:
- A gem or rune carries no stats at all. Its mods live in gems.txt keyed by the host's type, so
an Um in a helm contributes
All Resistances +15that appears nowhere in the record. - An item's own stats are split across its lists. A Tal Rasha's Horadric Crest holds
31 = 76on the base array and31 = 45on the affix list. The tooltip printsDefense: 121; nothing in the chain holds 121. - Op 13 is unapplied.
+120% Enhanced Defenseand the base Defense are separate stats until the op resolves them.
ItemMergedStats merged = TooltipEngine.Embedded.MergedStats(item);
foreach (MergedStat stat in merged.Stats)
{
Console.WriteLine($"stat {stat.StatId} layer {stat.Layer} = {stat.Value}");
}| member | what it gives you |
|---|---|
Stats |
one entry per non-zero (StatId, Layer), ordered by LAYER then stat |
ExcludedPackedStats |
stat ids left out because their value is a packed encoding |
Values are raw, in the encoding the record carries: +60 to Life comes back as 60 << 8,
pre-nValShift. That is deliberate — a consumer's search bounds derive from the same itemstatcost
scale, and a display-scaled value would need a second scale beside it.
An op-13 percent survives beside the target it resolved onto: you get both item_armor_percent
and the Defense it contributed to, because the tooltip draws the percent as its own line. Summing
both would double count.
Packed encodings are excluded rather than summed — stat 204 packs (maxCharges << 8) + current,
and the by-time stats pack a triple. Adding two packed words produces a number that looks real and
is not. They are reported in ExcludedPackedStats and are absent from Stats rather than zero,
because a zero would satisfy every "at most N" bound. RolledStatRange.IsPackedStat(statId) is the
same test, so a caller need not derive its own.
Set bonuses are excluded by default, the same rule Ranges follows; IncludeSetBonuses folds
in the tiers the record already carries. Pass an item, not a wearer: IUnit.Items carries two
relations, and this reads it as socket fillers.
This is the one place the library deliberately does not reproduce the game. Everything else here is byte-exact, oddities included; this one is not, and the reason is that the behaviour being reproduced is a bug in Diablo II that costs the player stats.
ITEM_RecalcAllEquippedItems (0x4c1350) ends with a loop over the eleven body slots. It fires only
when GetItemQuality returns 5 — a set item, cmp eax, 5 at 0x4c15fd. For each one it calls
STATLIST_RemoveFromOwnerAndRecalc (0x4c1658), which detaches the item's whole stat list, then
rebuilds it with ITEM_ApplySocketableAndEquipStats(wearer, THE SET ITEM, 0) at 0x4c1661. That
second argument is the set item, not a filler, so the gem test (IsOfType(a2, 20), 0x4c0d30) and
the rune test (IsOfType(a2, 74), 0x4c0da3) both fail and it lands on ITEM_ProcessSetItemEquip,
which re-applies set state and nothing else.
Nothing re-applies the socket fillers. So a worn Tal Rasha's Horadric Crest with an Um in it
grants its wearer All Resistances +15 — its own set property alone — while the same helm in the
stash grants 30. The mods come back when the piece is re-socketed or re-equipped, and go again on the
next recalc. Non-set items are unaffected: a runeword keeps every rune.
A live capture shows both halves of it in one snapshot:
| item | quality | worn | the game's own tooltip string | fillers counted |
|---|---|---|---|---|
| Treachery | 2 | yes | Cold Resist +30% |
yes |
| Sanctuary | 2 | yes | All Resistances +66 |
yes |
| Call to Arms | 3 | yes | +278% Enhanced Damage (228 without) |
yes |
| Gemmed Thresher | 2 | no | +9 to Minimum Damage |
yes |
| Tal Rasha's Crest | 5 | yes | All Resistances +15 (30 without) |
no |
Four socketed non-set items list their fillers. The one set item does not, with the Um sitting in it.
We render 30 — what the item grants. Render, MergedStats and SocketFillerStats all agree,
unconditionally, and nothing in the API mentions the discard: there is no option to get 15 back and
no flag reporting that the game would.
The reasoning: an item must not appear to lose its rune because something equipped it. A stash search that indexed the discarded value would drop the item; a comparison between a worn piece and a spare would rank them differently for no reason but which one is currently on the body; and the value is not even stable in-game, since re-socketing restores it until the next recalc. Carrying the game's answer alongside ours would have meant every consumer choosing between two numbers on every read, which is a decision none of them wanted to make.
If you need the number the game is currently drawing on screen, the condition is: the item is
quality == 5, equipped, not broken, and holds a gem or rune. Subtract SocketFillerStats(filler, host) for each filler when all four hold.
What a single gem, rune or jewel grants the host it sits in, so a caller can attribute stats to the socket rather than only to the total.
IReadOnlyList<MergedStat> um = TooltipEngine.Embedded.SocketFillerStats(filler, host);The host matters: an Um is All Resistances +22 in a shield and +15 in a helm, and the difference
is gems.txt gemapplytype. A jewel carries its own affixes instead, and those are what come
back. Note the slot comes from gemapplytype, and a row that takes no sockets still reads 0 there,
so a non-empty result is not evidence that the host is socketable — ask Items for gemsockets if
that is the question.
The same values the WeaponDamage line writes, before they are written.
ItemDamage damage = TooltipEngine.Embedded.Damage(weapon, player);
foreach (ItemDamageRange line in damage.Lines)
{
Console.WriteLine($"{line.Kind}: {line.Min} to {line.Max} (modified: {line.Modified})");
}Lines is in display order and holds one entry per line the tooltip draws, so it is usually one —
OneHand or TwoHand, whichever the item's 2handed column selects. Three things make it more:
a throwable weapon adds a Throw line above its own, a throwing potion replaces everything
with a single ThrowingPotion line read from missiles.txt rather than from any stat, and a
Barbarian holding a 1or2handed weapon gets both a one-hand and a two-hand line. That last is
the only reason Damage takes a viewer at all; pass null and you get the single line.
Modified is what paints the numbers colour 3 — the merged value exceeding the base one at either
end, or a by-time damage stat contributing. Max is the number as drawn, so the single-line
path's max = min + 1 clamp has already been applied and the dual-wield path's has not, which is
why a Barbarian can be shown a line whose two ends are equal.
An empty Lines means no damage line at all: anything that is not a weapon, and a weapon whose own
stat 21 or 22 is negative — zero passes, and comes out as 0 to 1.
What this is not. Smite and Kick are a different writer reading items.txt bytes rather than
stats, and are not here. Neither is elemental damage, which the game draws as ordinary modifier
lines. And three parts of INV_CalcWeaponDamageRange are not reproduced — the max is read straight
off the item rather than as MAX(mergedMax, mergedMin) plus stats 272/273, and the wielder's own
damage stats are not merged in. Whether any of those three moves a shipped item is uncounted.
Treat these as "what the tooltip shows", which they exactly are, rather than as "what the game
computes".
ItemAppearance look = TooltipEngine.Embedded.Appearance(item);
string image = look.Image; // "rin3" — sprite name, fetched as image + ".dc6"
int color = look.Color; // 0-20 palette shift, -1 for none
int invTrans = look.InvTrans;// which transform table the shift indexes; 0 means no tint
bool tinted = look.IsTinted; // Color >= 0 && InvTrans != 0Image is not the item code, so don't shortcut it. Exceptional and elite tiers share the base
tier's art (xap, the exceptional Cap, resolves to cap), set and unique items get their own, and
the four types with a random inventory graphic append the rolled variant — a ring is
rin1..rin5, which is the only thing gfxIndex in the record is for.
ItemRequirements req = TooltipEngine.Embedded.Requirements(item, player);
int strength = req.Strength;
int level = req.Level;
int classRestriction = req.ClassRestriction; // 0-6, or 7 for unrestricted
bool metStrength = req.MetStrength;
bool metLevel = req.MetLevel;The numbers do not depend on the viewer, but the flags do. With no viewer MetStrength and
MetDexterity read false — a null unit's stats read as 0 and the test is available > 0 && available >= required, so even a zero requirement fails. MetLevel has no > 0 guard, so it reads
TRUE whenever the required level is 0, which is the ordinary case; MetClass is true unless the
item is class-restricted. Pass a viewer if you care about the flags.
(That is only the API. The rendered tooltip does not paint anything red without a viewer.)
IReadOnlyList<int> swords = TooltipEngine.Embedded.ClassIdsOfType("swor");Every classId whose type chains up to swor, including exceptional and elite tiers and the
class-specific sword types.
An identified set item uses a different builder, and Render routes to it automatically. Which
sibling pieces count — and which of those raise a bonus tier — is worked out from the viewer, so
there is nothing to assemble:
Tooltip tip = TooltipEngine.Embedded.Render(item, player);All it needs is that the player's Items carry their Location and, when equipped, their X:
var player = new Unit();
player.UnitType = 0;
player.Items.Add(halo); // halo.Location = 1 (equipped), halo.X = 6 (ring slot)
player.Items.Add(mantle); // mantle.Location = 1, mantle.X = 3 (torso)
player.Items.Add(wings); // wings.Location = 3 (inventory)That gives the piece list its colours — carried pieces green, the rest red — selects the partial tiers, and renders the full-set block. A piece on the alternate weapon set is deliberately not the same as a worn one: it still colours green, because the game counts it as carried, but it lights no bit and raises no tier. That distinction is exactly why this is derived rather than handed over as a mask; body locations 11 and 12 are the swap pair, and counting them is the easiest way to show one bonus tier too many.
RangesForViewer takes the same viewer and folds in whichever set tiers it has earned:
ItemRollRanges ranges = TooltipEngine.Embedded.RangesForViewer(item, player);For a "what if I equipped the last piece" preview, hand it a viewer carrying the piece you are
imagining. There is no separate hypothetical-state API — a copy of the player with one more item in
Items is the hypothesis, and it goes through exactly the same derivation as the real one.
The engine hands you the parsed game tables for lookups it does not do for you. Every one is walked
the same way — RowCount for the bound, RowAt(index) for the row:
TooltipEngine engine = TooltipEngine.Embedded;
for (int classId = 0; classId < engine.Items.RowCount; ++classId)
{
ItemRow item = engine.Items.RowAt(classId);
Console.WriteLine($"{item.Code} tier={item.Tier} lvl={item.RequiredLevel}");
}RowAt returns null past the end rather than throwing. The same shape holds for engine.Types,
engine.Data.ItemStatCost, engine.Data.Skills, engine.Data.Classes, and the ColorTable,
GemTable and PropertiesTable you build yourself from engine.Data.
MagicAffixTable, MissileTable and SkillDamage do not follow it — they are keyed lookups
rather than row walks, so reach for their own accessors instead of RowAt.
Two tables have two row spaces and name their accessors after them instead:
engine.Sets.SetCount / engine.Sets.SetAt(i) // sets.txt
engine.Sets.PieceCount / engine.Sets.PieceAt(i) // setitems.txt — the ids a set item needs
engine.Data.MonsterTypes.MonsterCount / .MonsterAt(i)
engine.Data.MonsterTypes.MonsterTypeCount / .MonsterTypeAt(i)TxtFile is the raw column reader underneath all of them, and keeps RowCount with
GetString(row, column) / GetInt / GetBool — a row there has no fixed shape.
TypeScript is identical, camelCased: rowCount, rowAt, setAt, pieceAt, monsterAt.
If you would rather read a real MPQ extraction than use the embedded copy — a mod with altered tables, or a different locale:
TooltipEngine fromDisk = TooltipEngine.FromFiles(excelDir, localeDir, globalDir);
TooltipEngine fromTables = TooltipEngine.FromData(data); // tables you already holdTypeScript has TooltipEngine.fromFiles (Node-only) and fromData. Note the loader names differ:
C# has D2DataFiles.LoadEmbedded() and D2DataFiles.Load(dirs...); TypeScript has
D2DataFiles.load() for both.
A unit document is self-similar. items is what a unit contains: on an ITEM those are its socket
fillers, and position in the array is the socket index; on a WEARER they are the items it
carries, each with a location and — when equipped — an x giving the body slot.
That second form is what lets the library work out set state for you, so nothing has to hand it a bit mask:
{
"unitType": 4,
"classId": 442,
"statsLists": [
{ "stateNo": 0, "flags": 2147483648, "stats": [ { "id": 31, "value": 445 } ] },
{ "stateNo": 0, "flags": 64, "stats": [ { "id": 39, "value": 40 } ] },
{ "stateNo": 165, "flags": 8256, "stats": [ { "id": 0, "value": 20 } ] }
],
"items": [
{ "unitType": 4, "classId": 620 }
]
}flags and stateNo are copied verbatim from the statlist node and the consumer derives
everything from them — there is deliberately no "source" or classification field for you to fill
in, because getting it wrong is how a real bug got in.
| bit | meaning |
|---|---|
0x80000000 STATLIST_EXTENDED |
the base array |
0x40 STATLIST_MAGIC |
everything the item itself grants |
0x2000 STATLIST_SET |
the node sits on the pMyStats chain contributing nothing |
Set tiers are identified by stateNo 165-170, not by that bit.
A socket filler needs no stats of its own: a real client never instantiates them, so the engine
rebuilds a gem's or rune's contribution from gems.txt given the host it sits in.
Correctness here means byte-identical to the original, including its oddities — where the game is inconsistent, this reproduces the inconsistency rather than tidying it up. Every behaviour is traced to the instruction that causes it, and the code cites the address.
Two documented departures, both deliberate and both stated where they are seen: a worn set piece
keeps its socket fillers where the game discards them (the worn set piece
bug), and three parts of INV_CalcWeaponDamageRange are not reproduced
(see Damage). The second is untraced rather than
chosen, and its reachability is uncounted.
| differential corpus cases, C# vs TypeScript, agreeing layer by layer | 943 1.14d / 1151 RotW + 1136 D2R base, the RotW set in all 13 locales, HD and legacy |
| hostile producer-legal inputs, both engines agreeing | 11,972 |
| tests | 1263 C# / 1311 TypeScript |
| captured client tooltips reproduced byte-identically | 64 / 64 |
The capture set is a private captures.db of real client tooltips and is not part of this
repository, so that last row is the one number you cannot reproduce from a clone.
Everything below is a known gap rather than a bug. None of it throws; the output is simply absent or English-only.
Transaction-cost text. ShopMode 1-9 is accepted and suppresses the book usage lines, but no
price line is produced on the generic path at all — the routine that computes the price has not
been decompiled. On the set-item path you get the "cannot be traded here" refusal, which is real.
1.14d is English only. Its embedded tables are the ENG locale, and the 1.14d possessive
("Bob's Hat") is wired for English only. Point FromFiles at another locale's tables and the
strings change, but the possessive grammar does not. D2R speaks all thirteen of its locales —
ResurrectedTextOptions.Language takes enUS, deDE, esES, frFR, itIT, koKR, plPL,
ruRU, zhCN, zhTW, esMX, jaJP or ptBR, and item names follow the game's grammar header
(gendered adjectives) and per-language possessive, including the places the game itself prints a
raw gender tag such as de la ballena [fs]Corona.
Item level, when the capture omits it. Three property arms scale with the item's level: funcs
11 and 19 when the property's max is non-positive, and func 14's socket cap. The record carries
itemLevel for exactly this, but it is optional, and −1 means "not captured" rather than 0 — the
game's own floor is 1. Without it those arms floor at level 1 and say so through
ItemRollRanges.ItemLevelDependent rather than reporting a number they cannot know. Supply
itemLevel and they become exact. Note the example C++ producer does not emit it yet, so records
from it will always report these.
One property function is unimplemented. Func 9, and no shipped table carries it — a test walks
them and fails the build if that stops being true. Every other function is implemented and on the
live path: Ranges re-applies them to reconstruct roll spans.
The C++ producer is unfinished. An optional capture half that reads a live game's memory. You do not need it to use the library — only to generate records from a running client.
Diablo II: Resurrected, the parts that need state a record does not carry. A Warlock's
Levitate lowers weapon requirements, except on a throwing weapon while the last skill used was a
throwing one, and except while two melee weapons are equipped. Both are reproduced, but the first
needs the viewer's lastUsedSkill — without it the reduction is applied — and the second reads
the viewer's equipped items. The Metamorphosis runeword's "Mark of the Bear/Wolf" text runs a
subset of the skill description calculator — exactly the part those two rows use. One esMX string
(ModStr2uPercentNegative, Bone Break) makes the game print leftover stack memory as a float; that
output cannot be reproduced and is not. Only the hover tooltip is modelled: item links, chat and
the Chronicle preview are out of scope. With no viewer D2R draws nothing at all; the library still
renders, as it does for 1.14d.
Source, tests and the differential harness live at
github.com/ResurrectedTrader/D2ItemToolkit.
dotnet test and npm test run the two suites; the repository's CLAUDE.md documents how the two
implementations are kept in agreement and what the working rules are.
Source code is MIT.
data/ contains tables extracted from Diablo II 1.14d, and data/d2r/ tables and strings
extracted from Diablo II: Resurrected (data build 91735), embedded in the published packages so the
library works without a game install. Those files are the property of Blizzard Entertainment
and are not covered by the MIT licence. Diablo II is a trademark of Blizzard Entertainment,
Inc. This project is not affiliated with, endorsed by, or sponsored by Blizzard Entertainment.