Fix inventory.txt layout, item graphicIndex, misc.txt usables, weapon swap tabs, slot colors, and socket overlays (Closes #680, #681, #682, #683, #684, #685)

This commit is contained in:
troytt 2026-10-03 00:39:52 +00:00
parent f26fa9e636
commit 04bfa1a157
5 changed files with 1099 additions and 210 deletions

View File

@ -42,6 +42,7 @@ import {
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
CANONICAL_ONE_HANDED_WEAPON_CODES,
CANONICAL_SHIELD_AND_OFFHAND_CODES,
CANONICAL_VAR_INV_GFX,
STRICT_TWO_HANDED_WEAPON_CODES,
getEffectiveItemReqClass,
itemToUiInventoryItem,
@ -55,6 +56,7 @@ export {
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
CANONICAL_ONE_HANDED_WEAPON_CODES,
CANONICAL_SHIELD_AND_OFFHAND_CODES,
CANONICAL_VAR_INV_GFX,
STRICT_TWO_HANDED_WEAPON_CODES,
getEffectiveItemReqClass,
itemToUiInventoryItem,
@ -85,29 +87,338 @@ export {
type GridPlacement,
}
export const INV_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
export const INV_GRID_ORIGIN = { x: 400 + 18, y: 60 + 256, cols: 10, rows: 4, cellPx: 29 } as const
export type InventoryTxtBodySlotKey =
| 'rArm'
| 'torso'
| 'lArm'
| 'head'
| 'neck'
| 'rHand'
| 'lHand'
| 'belt'
| 'feet'
| 'gloves'
export interface InventoryTxtSlotRect {
readonly left: number
readonly right: number
readonly top: number
readonly bottom: number
readonly width: number
readonly height: number
}
export interface InventoryTxtRow {
readonly class: string
readonly invLeft: number
readonly invRight: number
readonly invTop: number
readonly invBottom: number
readonly invWidth: number
readonly invHeight: number
readonly gridX: number
readonly gridY: number
readonly gridLeft: number
readonly gridRight: number
readonly gridTop: number
readonly gridBottom: number
readonly gridBoxWidth: number
readonly gridBoxHeight: number
readonly slots: Readonly<Record<InventoryTxtBodySlotKey, InventoryTxtSlotRect>>
}
/**
* Ground Truth (`data\global\excel\inventory.txt` from Diablo II v1.13c `patch_d2.mpq`):
* All 32 canonical panel layout definitions (640x480 base rows + 800x600 `*2` rows).
*/
export const RAW_INVENTORY_TXT = [
'class\tinvLeft\tinvRight\tinvTop\tinvBottom\tgridX\tgridY\tgridLeft\tgridRight\tgridTop\tgridBottom\tgridBoxWidth\tgridBoxHeight\trArmLeft\trArmRight\trArmTop\trArmBottom\trArmWidth\trArmHeight\ttorsoLeft\ttorsoRight\ttorsoTop\ttorsoBottom\ttorsoWidth\ttorsoHeight\tlArmLeft\tlArmRight\tlArmTop\tlArmBottom\tlArmWidth\tlArmHeight\theadLeft\theadRight\theadTop\theadBottom\theadWidth\theadHeight\tneckLeft\tneckRight\tneckTop\tneckBottom\tneckWidth\tneckHeight\trHandLeft\trHandRight\trHandTop\trHandBottom\trHandWidth\trHandHeight\tlHandLeft\tlHandRight\tlHandTop\tlHandBottom\tlHandWidth\tlHandHeight\tbeltLeft\tbeltRight\tbeltTop\tbeltBottom\tbeltWidth\tbeltHeight\tfeetLeft\tfeetRight\tfeetTop\tfeetBottom\tfeetWidth\tfeetHeight\tglovesLeft\tglovesRight\tglovesTop\tglovesBottom\tglovesWidth\tglovesHeight',
'Amazon\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Sorceress\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Necromancer\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Paladin\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Barbarian\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Monster\t0\t320\t0\t441\t10\t10\t19\t306\t82\t370\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Trade Page 1\t0\t320\t0\t441\t10\t4\t19\t306\t12\t127\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Trade Page 2\t0\t320\t0\t441\t10\t4\t19\t306\t223\t338\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Bank Page 1\t0\t320\t0\t441\t6\t4\t74\t249\t143\t258\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Transmogrify Box Page 1\t0\t320\t0\t441\t3\t4\t116\t202\t139\t254\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Guild Vault Page 1\t0\t320\t0\t441\t8\t8\t46\t279\t88\t321\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Trophy Case Page 1\t0\t320\t0\t441\t2\t2\t111\t216\t141\t254\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Big Bank Page 1\t0\t320\t0\t441\t6\t8\t74\t249\t82\t315\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Hireling\t0\t320\t0\t441\t0\t0\t-1\t-1\t-1\t-1\t29\t29\t20\t75\t47\t159\t55\t112\t133\t189\t77\t159\t56\t82\t251\t306\t47\t159\t55\t112\t135\t189\t8\t59\t54\t51\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1',
'Druid\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Assassin\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
'Amazon2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
'Sorceress2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
'Necromancer2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
'Paladin2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
'Barbarian2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
'Monster2\t80\t400\t60\t501\t10\t10\t99\t386\t142\t430\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Trade Page 1-2\t80\t400\t60\t501\t10\t4\t99\t386\t72\t187\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Trade Page 2-2\t80\t400\t60\t501\t10\t4\t99\t386\t283\t398\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Bank Page 1-2\t80\t400\t60\t501\t6\t4\t154\t329\t203\t318\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Transmogrify Box Page 1-2\t80\t400\t60\t501\t3\t4\t196\t282\t199\t314\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Guild Vault Page 1-2\t80\t400\t60\t501\t8\t8\t126\t359\t148\t381\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Trophy Case Page 1-2\t80\t400\t60\t501\t2\t2\t191\t296\t201\t314\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Big Bank Page 1-2\t80\t400\t60\t501\t6\t8\t153\t325\t143\t373\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
'Hireling2\t80\t400\t60\t501\t0\t0\t-1\t-1\t-1\t-1\t29\t29\t100\t155\t107\t219\t55\t112\t213\t269\t137\t219\t56\t82\t331\t386\t107\t219\t55\t112\t215\t269\t68\t119\t54\t51\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1',
'Druid2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
'Assassin2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
].join('\n')
const INVENTORY_TXT_BODY_SLOT_KEYS: readonly InventoryTxtBodySlotKey[] = [
'rArm',
'torso',
'lArm',
'head',
'neck',
'rHand',
'lHand',
'belt',
'feet',
'gloves',
]
export function parseInventoryTxt(tsv: string): readonly InventoryTxtRow[] {
const lines = tsv
.split(/\r?\n/)
.map(l => l.trimEnd())
.filter(l => l.length > 0)
if (lines.length < 2) {
throw new Error('[inventory] Invalid inventory.txt: missing header or data rows')
}
const headers = lines[0]!.split('\t')
const colIndex = new Map<string, number>()
headers.forEach((h, i) => colIndex.set(h.trim(), i))
const num = (cols: string[], name: string): number => {
const idx = colIndex.get(name)
if (idx === undefined) {
throw new Error(`[inventory] Missing column "${name}" in inventory.txt`)
}
const val = Number(cols[idx])
if (!Number.isFinite(val)) {
throw new Error(`[inventory] Invalid numeric value "${cols[idx]}" for column "${name}" in inventory.txt`)
}
return val
}
const rows: InventoryTxtRow[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i]!.split('\t')
const className = (cols[colIndex.get('class') ?? 0] ?? '').trim()
if (!className || className.toLowerCase() === 'expansion') continue
const invLeft = num(cols, 'invLeft')
const invRight = num(cols, 'invRight')
const invTop = num(cols, 'invTop')
const invBottom = num(cols, 'invBottom')
const slots = {} as Record<InventoryTxtBodySlotKey, InventoryTxtSlotRect>
for (const key of INVENTORY_TXT_BODY_SLOT_KEYS) {
slots[key] = {
left: num(cols, `${key}Left`),
right: num(cols, `${key}Right`),
top: num(cols, `${key}Top`),
bottom: num(cols, `${key}Bottom`),
width: num(cols, `${key}Width`),
height: num(cols, `${key}Height`),
}
}
rows.push({
class: className,
invLeft,
invRight,
invTop,
invBottom,
invWidth: invRight - invLeft,
invHeight: invBottom - invTop,
gridX: num(cols, 'gridX'),
gridY: num(cols, 'gridY'),
gridLeft: num(cols, 'gridLeft'),
gridRight: num(cols, 'gridRight'),
gridTop: num(cols, 'gridTop'),
gridBottom: num(cols, 'gridBottom'),
gridBoxWidth: num(cols, 'gridBoxWidth'),
gridBoxHeight: num(cols, 'gridBoxHeight'),
slots,
})
}
return rows
}
export const INVENTORY_TXT_LAYOUTS: readonly InventoryTxtRow[] = parseInventoryTxt(RAW_INVENTORY_TXT)
export const INVENTORY_TXT_BY_CLASS: ReadonlyMap<string, InventoryTxtRow> = new Map(
INVENTORY_TXT_LAYOUTS.map(r => [r.class, r]),
)
const CLASS_CODE_TO_INVENTORY_TXT_NAME: Readonly<Record<string, string>> = {
ama: 'Amazon',
sor: 'Sorceress',
nec: 'Necromancer',
pal: 'Paladin',
bar: 'Barbarian',
dru: 'Druid',
ass: 'Assassin',
}
/**
* Looks up a canonical `inventory.txt` layout row by class code (`ama..ass`),
* class name (`Amazon..Assassin`), or panel name (`Big Bank Page 1`, `Hireling`, etc.)
* and resolution (`0` = 640x480, `1` = 800x600 `*2` / `*-2` rows).
* Fails fast if the requested layout does not exist.
*/
export function getInventoryLayout(classOrPanelName: string, resolution: 0 | 1 = 1): InventoryTxtRow {
const trimmed = classOrPanelName.trim()
const baseName = CLASS_CODE_TO_INVENTORY_TXT_NAME[trimmed.toLowerCase()] ?? trimmed
if (resolution === 1) {
if (baseName.endsWith('2') && INVENTORY_TXT_BY_CLASS.has(baseName)) {
return INVENTORY_TXT_BY_CLASS.get(baseName)!
}
const candidate2 = `${baseName}2`
if (INVENTORY_TXT_BY_CLASS.has(candidate2)) {
return INVENTORY_TXT_BY_CLASS.get(candidate2)!
}
const candidateDash2 = `${baseName}-2`
if (INVENTORY_TXT_BY_CLASS.has(candidateDash2)) {
return INVENTORY_TXT_BY_CLASS.get(candidateDash2)!
}
}
const exact = INVENTORY_TXT_BY_CLASS.get(baseName)
if (!exact) {
throw new Error(`[inventory] Unknown inventory.txt layout "${classOrPanelName}" (resolution=${resolution})`)
}
return exact
}
const DEFAULT_INV_LAYOUT_800 = getInventoryLayout('Amazon2', 1)
export const INV_PANEL_ORIGIN = {
x: DEFAULT_INV_LAYOUT_800.invLeft,
y: DEFAULT_INV_LAYOUT_800.invTop,
width: DEFAULT_INV_LAYOUT_800.invWidth,
height: DEFAULT_INV_LAYOUT_800.invHeight,
} as const
export const INV_GRID_ORIGIN = {
x: DEFAULT_INV_LAYOUT_800.gridLeft,
y: DEFAULT_INV_LAYOUT_800.gridTop,
cols: DEFAULT_INV_LAYOUT_800.gridX,
rows: DEFAULT_INV_LAYOUT_800.gridY,
cellPx: DEFAULT_INV_LAYOUT_800.gridBoxWidth,
} as const
export const EQUIP_SLOTS_LAYOUT: Readonly<
Record<EquipSlotId, { readonly x: number; readonly y: number; readonly w: number; readonly h: number; readonly label: string }>
> = {
helm: { x: 400 + 132, y: 60 + 4, w: 58, h: 58, label: '头盔' },
amulet: { x: 400 + 204, y: 60 + 33, w: 29, h: 29, label: '项链' },
weapon1: { x: 400 + 18, y: 60 + 47, w: 58, h: 116, label: '主手武器' },
armor: { x: 400 + 132, y: 60 + 76, w: 58, h: 87, label: '护甲' },
weapon2: { x: 400 + 248, y: 60 + 47, w: 58, h: 116, label: '副手/盾牌' },
gloves: { x: 400 + 18, y: 60 + 179, w: 58, h: 58, label: '手套' },
ring1: { x: 400 + 92, y: 60 + 179, w: 29, h: 29, label: '左戒指' },
belt: { x: 400 + 132, y: 60 + 179, w: 58, h: 29, label: '腰带' },
ring2: { x: 400 + 204, y: 60 + 179, w: 29, h: 29, label: '右戒指' },
boots: { x: 400 + 248, y: 60 + 179, w: 58, h: 58, label: '靴子' },
helm: {
x: DEFAULT_INV_LAYOUT_800.slots.head.left,
y: DEFAULT_INV_LAYOUT_800.slots.head.top,
w: DEFAULT_INV_LAYOUT_800.slots.head.width,
h: DEFAULT_INV_LAYOUT_800.slots.head.height,
label: '头盔',
},
amulet: {
x: DEFAULT_INV_LAYOUT_800.slots.neck.left,
y: DEFAULT_INV_LAYOUT_800.slots.neck.top,
w: DEFAULT_INV_LAYOUT_800.slots.neck.width,
h: DEFAULT_INV_LAYOUT_800.slots.neck.height,
label: '项链',
},
weapon1: {
x: DEFAULT_INV_LAYOUT_800.slots.rArm.left,
y: DEFAULT_INV_LAYOUT_800.slots.rArm.top,
w: DEFAULT_INV_LAYOUT_800.slots.rArm.width,
h: DEFAULT_INV_LAYOUT_800.slots.rArm.height,
label: '主手武器',
},
armor: {
x: DEFAULT_INV_LAYOUT_800.slots.torso.left,
y: DEFAULT_INV_LAYOUT_800.slots.torso.top,
w: DEFAULT_INV_LAYOUT_800.slots.torso.width,
h: DEFAULT_INV_LAYOUT_800.slots.torso.height,
label: '护甲',
},
weapon2: {
x: DEFAULT_INV_LAYOUT_800.slots.lArm.left,
y: DEFAULT_INV_LAYOUT_800.slots.lArm.top,
w: DEFAULT_INV_LAYOUT_800.slots.lArm.width,
h: DEFAULT_INV_LAYOUT_800.slots.lArm.height,
label: '副手/盾牌',
},
gloves: {
x: DEFAULT_INV_LAYOUT_800.slots.gloves.left,
y: DEFAULT_INV_LAYOUT_800.slots.gloves.top,
w: DEFAULT_INV_LAYOUT_800.slots.gloves.width,
h: DEFAULT_INV_LAYOUT_800.slots.gloves.height,
label: '手套',
},
ring1: {
x: DEFAULT_INV_LAYOUT_800.slots.rHand.left,
y: DEFAULT_INV_LAYOUT_800.slots.rHand.top,
w: DEFAULT_INV_LAYOUT_800.slots.rHand.width,
h: DEFAULT_INV_LAYOUT_800.slots.rHand.height,
label: '左戒指',
},
belt: {
x: DEFAULT_INV_LAYOUT_800.slots.belt.left,
y: DEFAULT_INV_LAYOUT_800.slots.belt.top,
w: DEFAULT_INV_LAYOUT_800.slots.belt.width,
h: DEFAULT_INV_LAYOUT_800.slots.belt.height,
label: '腰带',
},
ring2: {
x: DEFAULT_INV_LAYOUT_800.slots.lHand.left,
y: DEFAULT_INV_LAYOUT_800.slots.lHand.top,
w: DEFAULT_INV_LAYOUT_800.slots.lHand.width,
h: DEFAULT_INV_LAYOUT_800.slots.lHand.height,
label: '右戒指',
},
boots: {
x: DEFAULT_INV_LAYOUT_800.slots.feet.left,
y: DEFAULT_INV_LAYOUT_800.slots.feet.top,
w: DEFAULT_INV_LAYOUT_800.slots.feet.width,
h: DEFAULT_INV_LAYOUT_800.slots.feet.height,
label: '靴子',
},
}
/**
* Ground Truth (`Panel\invchar6Tab.dc6` frames 0 & 1, 62x22 px each, drawn at `ox + 15, oy + 23` and `ox + 245, oy + 23`):
* Each 62x22 weapon swap tab strip has two 31x22 sub-tabs:
* - Left half (`w = 31`): Weapon Set `I` (`weaponSwapSet = 0`)
* - Right half (`w = 31`): Weapon Set `II` (`weaponSwapSet = 1`)
*/
export const WEAPON_SWAP_TABS_BOUNDS = {
left: { x: 400 + 16, y: 60 + 24, w: 60, h: 21 },
right: { x: 400 + 246, y: 60 + 24, w: 60, h: 21 },
left: { x: 400 + 15, y: 60 + 23, w: 62, h: 22 },
right: { x: 400 + 245, y: 60 + 23, w: 62, h: 22 },
leftI: { x: 400 + 15, y: 60 + 23, w: 31, h: 22, set: 0 as const },
leftII: { x: 400 + 46, y: 60 + 23, w: 31, h: 22, set: 1 as const },
rightI: { x: 400 + 245, y: 60 + 23, w: 31, h: 22, set: 0 as const },
rightII: { x: 400 + 276, y: 60 + 23, w: 31, h: 22, set: 1 as const },
} as const
export function resolveWeaponSwapTabClick(logicalX: number, logicalY: number): 0 | 1 | null {
for (const subTab of [
WEAPON_SWAP_TABS_BOUNDS.leftI,
WEAPON_SWAP_TABS_BOUNDS.leftII,
WEAPON_SWAP_TABS_BOUNDS.rightI,
WEAPON_SWAP_TABS_BOUNDS.rightII,
]) {
if (
logicalX >= subTab.x &&
logicalX < subTab.x + subTab.w &&
logicalY >= subTab.y &&
logicalY <= subTab.y + subTab.h
) {
return subTab.set
}
}
return null
}
export const INV_CLOSE_BTN_BOUNDS = { x: 400 + 18, y: 60 + 385, w: 32, h: 32 } as const
/**
@ -165,6 +476,7 @@ export interface ResolvableItem {
readonly invtransform?: string | undefined
readonly chrtransform?: string | undefined
readonly variant?: number | undefined
readonly graphicIndex?: number | undefined
readonly id?: string | number | undefined
readonly invWidth?: number | undefined
readonly invHeight?: number | undefined
@ -234,124 +546,47 @@ export function resolveItemSpriteRect(
}
}
// Tier 0.5: Authentic Diablo II 1.13c Charm Resolution (cm1, cm2, cm3, Annihilus, Torch, Gheed's)
if (item.code === 'cm1' || item.code === 'anni' || item.name?.includes('Annihilus')) {
const validSmall = ['invch1', 'invch4', 'invch7', 'invmss']
if (item.variant !== undefined && !item.name?.includes('Annihilus') && item.code !== 'anni') {
const v = ['invch1', 'invch4', 'invch7'][Math.abs(Math.floor(item.variant)) % 3]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validSmall.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
if (item.code === 'anni' || item.name?.includes('Annihilus')) {
return direct ?? itemRects['invmss'] ?? itemRects['invch1'] ?? null
}
return direct ?? itemRects['invch1'] ?? itemRects['invmss'] ?? null
}
if (item.code === 'cm2' || item.code === 'torch' || item.name?.includes('Torch')) {
const validLarge = ['invch2', 'invch5', 'invch8', 'invtrch']
if (item.variant !== undefined && !item.name?.includes('Torch') && item.code !== 'torch') {
const v = ['invch2', 'invch5', 'invch8'][Math.abs(Math.floor(item.variant)) % 3]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validLarge.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
if (item.code === 'torch' || item.name?.includes('Torch')) {
return direct ?? itemRects['invtrch'] ?? itemRects['invch2'] ?? null
}
return direct ?? itemRects['invch2'] ?? itemRects['invtrch'] ?? null
}
if (item.code === 'cm3' || item.code === 'gheeds' || item.name?.includes('Gheed')) {
const validGrand = ['invch3', 'invch6', 'invch9']
if (item.variant !== undefined) {
const v = validGrand[Math.abs(Math.floor(item.variant)) % validGrand.length]
if (itemRects[v]) return itemRects[v]
}
const file = item.invFile?.toLowerCase()
const direct = file && validGrand.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
return direct ?? itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
}
// Tier 0.5: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Charms, Rings, Amulets, Jewels)
const lowerCode = item.code?.toLowerCase()
const lowerInv = item.invFile?.toLowerCase()
const varGfxCode =
lowerCode && CANONICAL_VAR_INV_GFX[lowerCode]
? lowerCode
: lowerInv && /^invrin\d?$/.test(lowerInv)
? 'rin'
: lowerInv && /^invamu\d?$/.test(lowerInv)
? 'amu'
: lowerInv && /^(invjw|invjew)\d?$/.test(lowerInv)
? 'jew'
: undefined
const varGfxList = varGfxCode ? CANONICAL_VAR_INV_GFX[varGfxCode] : undefined
// Tier 0.6: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Ring: 5, Amulet: 3, Jewel: 6)
if (item.code === 'rin' || item.code === 'ring' || (item.invFile && /^invrin\d?$/i.test(item.invFile))) {
const ringVariants = ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % ringVariants.length
const key = ringVariants[idx]
if (varGfxList) {
const rawVariant = item.variant ?? item.graphicIndex
if (typeof rawVariant === 'number' && Number.isFinite(rawVariant) && rawVariant >= 0) {
const key = varGfxList[Math.trunc(rawVariant) % varGfxList.length]!
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) {
return itemRects[item.invFile]
if (lowerInv && (varGfxList.includes(lowerInv) || lowerInv === 'invmss' || lowerInv === 'invtrch')) {
const rect = itemRects[item.invFile!] ?? itemRects[lowerInv]
if (rect) return rect
}
if (item.id !== undefined && item.id !== '') {
const idStr = String(item.id)
let hash = 0
for (let i = 0; i < idStr.length; i++) {
hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
if (item.name) {
const fromName = BAKED_UI_MANIFEST.codeToInvFile[item.name]
if (
fromName &&
(varGfxList.includes(fromName) || fromName === 'invmss' || fromName === 'invtrch') &&
itemRects[fromName]
) {
return itemRects[fromName]
}
const idx = Math.abs(hash) % ringVariants.length
const key = ringVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invrin1'] ?? itemRects['invrin'] ?? null
const defaultKey = varGfxList[0]!
return itemRects[defaultKey] ?? null
}
if (item.code === 'amu' || item.code === 'amul' || (item.invFile && /^invamu\d?$/i.test(item.invFile))) {
const amuVariants = ['invamu1', 'invamu2', 'invamu3']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % amuVariants.length
const key = amuVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && item.invFile.toLowerCase() !== 'invamu' && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]
}
if (item.id !== undefined && item.id !== '') {
const idStr = String(item.id)
let hash = 0
for (let i = 0; i < idStr.length; i++) {
hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % amuVariants.length
const key = amuVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invamu1'] ?? itemRects['invamu'] ?? null
}
if (item.code === 'jew' || item.code === 'jewl' || (item.invFile && /^(invjw|invjew)\d?$/i.test(item.invFile))) {
const jewelVariants = ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6']
if (item.variant !== undefined) {
const idx = Math.abs(Math.floor(item.variant)) % jewelVariants.length
const key = jewelVariants[idx]
if (itemRects[key]) return itemRects[key]
}
if (item.invFile && itemRects[item.invFile]) {
return itemRects[item.invFile]
}
if (item.id !== undefined && item.id !== '') {
const idStr = String(item.id)
let hash = 0
for (let i = 0; i < idStr.length; i++) {
hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
}
const idx = Math.abs(hash) % jewelVariants.length
const key = jewelVariants[idx]
if (itemRects[key]) return itemRects[key]
}
return itemRects['invjw1'] ?? null
}
// Tier 1: Direct invFile (ignore misc.txt placeholder invfiles for charms)
const isCharmPlaceholder =
(item.code === 'cm3' && item.invFile?.toLowerCase() === 'invsst') ||
(item.code === 'cm2' && item.invFile?.toLowerCase() === 'invwnd') ||
(item.code === 'cm1' && item.invFile?.toLowerCase() === 'invchm')
if (item.invFile && !isCharmPlaceholder) {
// Tier 1: Direct invFile
if (item.invFile) {
if (itemRects[item.invFile]) return itemRects[item.invFile]
const lower = item.invFile.toLowerCase()
if (itemRects[lower]) return itemRects[lower]
@ -372,10 +607,7 @@ export function resolveItemSpriteRect(
// Tier 4: Canonical Diablo II 1.13c specific code overrides
if (item.code === 'tbk') return itemRects['invbbk'] ?? null
if (item.code === 'ibk') return itemRects['invrbk'] ?? null
if (item.code === 'box' || item.code === 'cube') return itemRects['invbox'] ?? null
if (item.code === 'crs' || item.code === 'cta' || item.name?.includes('Call to Arms')) {
return itemRects['invcrs'] ?? null
}
if (item.code === 'box') return itemRects['invbox'] ?? null
return null
}
@ -500,9 +732,21 @@ export function isScrollForTome(scrollCode: string | undefined, tomeCode: string
return (sc === 'tsc' && tc === 'tbk') || (sc === 'isc' && tc === 'ibk')
}
/**
* Ground Truth (`data\global\excel\misc.txt` `useable === 1` in Diablo II v1.13c):
* The exact 35 miscellaneous item codes that can be activated via right-click in the inventory.
*/
export const CANONICAL_MISC_USABLE_CODES: ReadonlySet<string> = new Set([
'elx', 'hpo', 'mpo', 'hpf', 'mpf', 'vps', 'yps', 'rvs', 'rvl', 'wms',
'tbk', 'ibk', 'tsc', 'isc', 'xyz', 'box', 'ass',
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
'hrb', 'rps', 'rpl', 'bps', 'bpl', '0sc', 'tr2', 'toa',
])
export function isUsableRightClickItem(code: string | undefined): boolean {
const c = (code ?? '').trim().toLowerCase()
return c === 'box' || c === 'cube' || c === 'tbk' || c === 'tsc' || c === 'ibk' || c === 'isc'
return CANONICAL_MISC_USABLE_CODES.has(c)
}
export function getTomeQuantity(item: UiInventoryItem): number {
@ -779,6 +1023,30 @@ export function resolveGridItemBackgroundFill(
: GRID_ITEM_BG_RED
}
/**
* Ground Truth (D2Client.dll 1.13c @ 0x6fb47be0 `INVENTORY_DrawEquippedItems`):
* Paperdoll equipment slots do NOT draw a green background for normal usable equipped items
* (the dark carved slot recess from `invchar6.dc6` shows through); a background fill is only
* drawn when the equipped item fails requirements (`GRID_ITEM_BG_RED`) or when hovered
* while holding a cursor item (`GRID_ITEM_BG_GREEN`).
*/
export function resolvePaperdollSlotBackgroundFill(
item: UiInventoryItem,
player: PlayerItemRequirementContext,
options?: {
readonly isHovered?: boolean | undefined
readonly hasCursorItem?: boolean | undefined
},
): string | null {
if (options?.isHovered && options?.hasCursorItem) {
return GRID_ITEM_BG_GREEN
}
if (!canPlayerUseUiItem(item, player, 'trade')) {
return GRID_ITEM_BG_RED
}
return null
}
export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true
;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false
@ -1430,8 +1698,14 @@ export function applySocketToUiItem(
}
/**
* Draws the authentic Diablo II socket circles and any socketed Gem/Rune/Jewel icons
* overlaid on a socketed item in the Inventory, Equipment slot, Stash, or Horadric Cube.
* Canonical 28x28 `data\global\ui\PANEL\gemsocket.dc6` frame 0 sprite dimensions.
*/
export const GEMSOCKET_SPRITE_RECT: SpriteRect = { x: 0, y: 0, w: 28, h: 28 }
/**
* Draws the authentic Diablo II `gemsocket.dc6` socket frame and any socketed Gem/Rune/Jewel icons
* overlaid on a hovered socketed item in the Inventory, Equipment slot, Stash, or Horadric Cube.
* Never draws procedural circles or colored dots; fails fast if a socketed item sprite is missing.
*/
export function drawItemSocketOverlay(
ctx: CanvasRenderingContext2D,
@ -1442,16 +1716,24 @@ export function drawItemSocketOverlay(
h: number,
assets: {
itemsAtlasImg: HTMLImageElement | null
gemSocketImg?: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
options?: {
readonly isHovered?: boolean | undefined
},
): void {
if (options?.isHovered === false) return
const totalSockets = Math.min(6, Math.max(0, item.sockets ?? 0))
if (totalSockets <= 0 || isUiItemUnidentified(item)) return
const socketed = item.socketedItems ?? []
const twoCols = totalSockets >= 4 && item.invWidth >= 2
const rows = twoCols ? Math.ceil(totalSockets / 2) : totalSockets
const radius = 9
const socketRect =
assets.itemRects['gemsocket'] ??
BAKED_UI_MANIFEST.itemRects['gemsocket'] ??
GEMSOCKET_SPRITE_RECT
for (let idx = 0; idx < totalSockets; idx++) {
let cx: number
@ -1468,41 +1750,52 @@ export function drawItemSocketOverlay(
}
ctx.save?.()
if (typeof ctx.arc === 'function') {
ctx.beginPath?.()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(10, 10, 14, 0.74)'
ctx.fill?.()
ctx.lineWidth = 1
ctx.strokeStyle = 'rgba(168, 152, 118, 0.78)'
ctx.stroke?.()
if (assets.gemSocketImg) {
ctx.drawImage(
assets.gemSocketImg,
0,
0,
GEMSOCKET_SPRITE_RECT.w,
GEMSOCKET_SPRITE_RECT.h,
Math.round(cx - GEMSOCKET_SPRITE_RECT.w / 2),
Math.round(cy - GEMSOCKET_SPRITE_RECT.h / 2),
GEMSOCKET_SPRITE_RECT.w,
GEMSOCKET_SPRITE_RECT.h,
)
} else if (assets.itemsAtlasImg) {
ctx.drawImage(
assets.itemsAtlasImg,
socketRect.x,
socketRect.y,
socketRect.w,
socketRect.h,
Math.round(cx - socketRect.w / 2),
Math.round(cy - socketRect.h / 2),
socketRect.w,
socketRect.h,
)
}
const sockItem = socketed[idx]
if (sockItem) {
const sr = resolveItemSpriteRect(sockItem, assets.itemRects)
if (assets.itemsAtlasImg && sr) {
const iconSize = 16
if (!sr) {
throw new Error(
`[inventory] Missing sprite rect for socketed item code="${sockItem.code ?? ''}" invFile="${sockItem.invFile ?? ''}"`,
)
}
if (assets.itemsAtlasImg) {
ctx.drawImage(
assets.itemsAtlasImg,
sr.x,
sr.y,
sr.w,
sr.h,
Math.round(cx - iconSize / 2),
Math.round(cy - iconSize / 2),
iconSize,
iconSize,
Math.round(cx - sr.w / 2),
Math.round(cy - sr.h / 2),
sr.w,
sr.h,
)
} else if (typeof ctx.arc === 'function') {
ctx.beginPath?.()
ctx.arc(cx, cy, 5, 0, Math.PI * 2)
ctx.fillStyle = isRuneCode(sockItem.code)
? '#e09840'
: isJewelCode(sockItem.code)
? '#68a8ff'
: '#e8d070'
ctx.fill?.()
}
}
ctx.restore?.()
@ -1987,7 +2280,7 @@ export class InventoryPanel {
}
/**
* Right-click use handler for usable items (`box`, `tbk`, `tsc`, `ibk`, `isc`) inside a container.
* Right-click use handler for usable items (`misc.txt` `useable === 1`) inside a container.
*/
usePlacedItemByRightClick(
placed: GridPlacement,
@ -1996,11 +2289,13 @@ export class InventoryPanel {
callbacks?: {
onOpenCube?: () => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
onUseItem?: (item: UiInventoryItem, container: 'inventory' | 'stash' | 'cube') => boolean | void
},
): boolean {
if (this.cursorItem !== null) return false
const code = (placed.item.code ?? '').trim().toLowerCase()
if (code === 'box' || code === 'cube') {
if (!CANONICAL_MISC_USABLE_CODES.has(code)) return false
if (code === 'box') {
callbacks?.onOpenCube?.()
return true
}
@ -2032,7 +2327,20 @@ export class InventoryPanel {
if (code === 'ibk' || code === 'isc') {
return this.startIdentifyMode(placed.item, container)
}
return false
const usedOk = callbacks?.onUseItem?.(placed.item, container)
if (usedOk !== false) {
const qty = placed.item.quantity ?? placed.item.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(placed.item, qty - 1)
} else {
const idx = containerList.indexOf(placed)
if (idx !== -1) containerList.splice(idx, 1)
if (this.hoveredItem?.item.id === placed.item.id) {
this.hoveredItem = null
}
}
}
return true
}
handleMouseMove(logicalX: number, logicalY: number): void {
@ -2073,7 +2381,7 @@ export class InventoryPanel {
}
/**
* Handle mouse click on the Inventory Panel (`400..720, 60..492`).
* Handle mouse click on the Inventory Panel (`400..720, 60..501`).
*
* Ground Truth (Diablo II v1.13c):
* - `button === 0` (Left-Click):
@ -2081,7 +2389,7 @@ export class InventoryPanel {
* - In normal mode: picks up, places, swaps items in the 10x4 grid or equipment slots; stacks scrolls (`tsc`/`isc`) into matching tomes (`tbk`/`ibk`). Never uses `box` or `tbk`!
* - `button === 2` (Right-Click):
* - In Identify Cursor Mode: cancels Identify mode.
* - In normal mode: uses usable items (`box` opens Horadric Cube, `tbk`/`tsc` casts Town Portal and consumes 1 charge/scroll, `ibk`/`isc` enters Identify Cursor Mode). Never picks up or moves items onto the cursor!
* - In normal mode: uses usable items (`misc.txt` `useable === 1`: `box` opens Horadric Cube, `tbk`/`tsc` casts Town Portal and consumes 1 charge/scroll, `ibk`/`isc` enters Identify Cursor Mode, potions/usables consume 1 item). Never picks up or moves items onto the cursor!
*/
handleClick(
logicalX: number,
@ -2089,6 +2397,7 @@ export class InventoryPanel {
callbacks?: {
onOpenCube?: () => void
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
onUseItem?: (item: UiInventoryItem, container: 'inventory' | 'stash' | 'cube') => boolean | void
/** Inventory gold coin button pressed: open the gold dialog (drop, or deposit while the stash is open). */
onGoldButton?: () => void
},
@ -2177,12 +2486,13 @@ export class InventoryPanel {
return true
}
// 2. Weapon Swap `I / II` Tabs
for (const tab of [WEAPON_SWAP_TABS_BOUNDS.left, WEAPON_SWAP_TABS_BOUNDS.right]) {
if (logicalX >= tab.x && logicalX <= tab.x + tab.w && logicalY >= tab.y && logicalY <= tab.y + tab.h) {
// 2. Weapon Swap `I / II` Tabs (sub-tabs I -> set 0, II -> set 1; clicking active tab is a no-op)
const targetSwapSet = resolveWeaponSwapTabClick(logicalX, logicalY)
if (targetSwapSet !== null) {
if (this.weaponSwapSet !== targetSwapSet) {
this.toggleWeaponSwap()
return true
}
return true
}
// 3. Equipment slots
@ -2230,6 +2540,7 @@ export class InventoryPanel {
invTab1Img: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
goldCoinBtnImg?: HTMLImageElement | null
gemSocketImg?: HTMLImageElement | null
itemsAtlasImg: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
@ -2242,7 +2553,7 @@ export class InventoryPanel {
ctx.drawImage(assets.borderRightImg, 400, 0)
}
// 2. Draw 320x432 Inventory Panel (`invchar6.dc6` frames 4..7) at (400, 60)
// 2. Draw 320x441 Inventory Panel (`invchar6.dc6` frames 4..7) at (400, 60)
if (assets.invSheetImg) {
ctx.drawImage(assets.invSheetImg, INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y)
} else {
@ -2250,51 +2561,43 @@ export class InventoryPanel {
ctx.fillRect(INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y, INV_PANEL_ORIGIN.width, INV_PANEL_ORIGIN.height)
}
// 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6`)
// 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6` frames 0 & 1 already contain the carved Roman numerals I and II)
const tabImg = this.weaponSwapSet === 0 ? assets.invTab0Img : assets.invTab1Img
if (tabImg) {
ctx.drawImage(tabImg, 400 + 15, 60 + 23)
ctx.drawImage(tabImg, 400 + 245, 60 + 23)
ctx.drawImage(tabImg, WEAPON_SWAP_TABS_BOUNDS.left.x, WEAPON_SWAP_TABS_BOUNDS.left.y)
ctx.drawImage(tabImg, WEAPON_SWAP_TABS_BOUNDS.right.x, WEAPON_SWAP_TABS_BOUNDS.right.y)
}
font.drawText(ctx, this.weaponSwapSet === 0 ? 'I' : 'II', 400 + 46, 60 + 38, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, this.weaponSwapSet === 0 ? 'I' : 'II', 400 + 276, 60 + 38, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
// 4. Draw Equipped Items in Paperdoll Slots
// 4. Draw Equipped Items in Paperdoll Slots (no background fill for usable items; red only when unusable, green when hovered with cursor item)
for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) {
const eq = this.equipped[slotId]
if (!eq) continue
ctx.fillStyle = canPlayerUseUiItem(eq, this.playerContext)
? 'rgba(18, 42, 24, 0.52)'
: 'rgba(72, 18, 18, 0.50)'
ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
this.drawItemSprite(ctx, eq, rect.x, rect.y, rect.w, rect.h, assets)
const isHovered = this.hoveredItem?.item === eq
const slotFill = resolvePaperdollSlotBackgroundFill(eq, this.playerContext, {
isHovered,
hasCursorItem: this.cursorItem !== null,
})
if (slotFill) {
ctx.fillStyle = slotFill
ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
}
this.drawItemSprite(ctx, eq, rect.x, rect.y, rect.w, rect.h, assets, isHovered)
}
// 5. Draw 10x4 Bag Grid Items
// 5. Draw 10x4 Bag Grid Items (D2Client.dll 0x6fb459f7: blue for usable, red for unusable/unidentified, green when hovered with cursor item)
for (const placed of this.gridItems) {
const gx = INV_GRID_ORIGIN.x + placed.col * INV_GRID_ORIGIN.cellPx
const gy = INV_GRID_ORIGIN.y + placed.row * INV_GRID_ORIGIN.cellPx
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
const isHovered = this.hoveredItem?.item === placed.item
ctx.fillStyle =
isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, this.playerContext)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillStyle = resolveGridItemBackgroundFill(placed.item, this.playerContext, {
isHovered,
hasCursorItem: this.cursorItem !== null,
})
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets)
this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets, isHovered)
}
// 6. Gold Coin Button (`Panel\goldcoinbtn.dc6` frame 0 at x=484, y=451, 20x18) & Gold Amount Readout
@ -2342,18 +2645,28 @@ export class InventoryPanel {
h: number,
assets: {
itemsAtlasImg: HTMLImageElement | null
gemSocketImg?: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
isHovered = false,
): void {
const sr = resolveItemSpriteRect(item, assets.itemRects)
if (assets.itemsAtlasImg && sr) {
if (!sr) {
throw new Error(
`[inventory] Missing sprite rect for item code="${item.code ?? ''}" invFile="${item.invFile ?? ''}"`,
)
}
if (assets.itemsAtlasImg) {
const dx = x + Math.floor((w - sr.w) / 2)
const dy = y + Math.floor((h - sr.h) / 2)
ctx.drawImage(assets.itemsAtlasImg, sr.x, sr.y, sr.w, sr.h, dx, dy, sr.w, sr.h)
} else {
ctx.strokeStyle = '#c8a15a'
ctx.strokeRect(x + 2, y + 2, w - 4, h - 4)
}
drawItemSocketOverlay(ctx, item, x, y, w, h, assets)
if (isHovered) {
drawItemSocketOverlay(ctx, item, x, gyOrY(y), w, h, assets, { isHovered: true })
}
}
}
function gyOrY(y: number): number {
return y
}

View File

@ -554,6 +554,27 @@ export function resolveItemColorTransform(
}
}
/**
* Ground Truth (`data\global\excel\ItemTypes.txt` `VarInvGfx` + `InvGfx1..6` in Diablo II v1.13c):
* Maps item codes and item type codes with variable inventory graphics (`VarInvGfx > 0`)
* to their exact 0-indexed `InvGfx1..6` sprite keys (`graphicIndex = 0..N-1`).
*/
export const CANONICAL_VAR_INV_GFX: Readonly<Record<string, readonly string[]>> = {
rin: ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5'],
ring: ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5'],
amu: ['invamu1', 'invamu2', 'invamu3'],
amul: ['invamu1', 'invamu2', 'invamu3'],
jew: ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6'],
jewl: ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6'],
cm1: ['invch1', 'invch4', 'invch7'],
scha: ['invch1', 'invch4', 'invch7'],
char: ['invch1', 'invch4', 'invch7'],
cm2: ['invch2', 'invch5', 'invch8'],
mcha: ['invch2', 'invch5', 'invch8'],
cm3: ['invch3', 'invch6', 'invch9'],
lcha: ['invch3', 'invch6', 'invch9'],
}
/**
* Resolves the DC6 inventory graphic sprite key (`invFile`) following
* the authentic Diablo II 1.13c resolution hierarchy and guaranteeing
@ -570,6 +591,17 @@ export function resolveItemInvFile(
let invtransform: string | undefined = resolvedColors.invtransform
const chrtransform: string | undefined = resolvedColors.chrtransform
const rawVariant =
typeof (item as any).variant === 'number' && Number.isFinite((item as any).variant) && (item as any).variant >= 0
? Math.trunc((item as any).variant)
: typeof (item as any).graphicIndex === 'number' && Number.isFinite((item as any).graphicIndex) && (item as any).graphicIndex >= 0
? Math.trunc((item as any).graphicIndex)
: undefined
const varGfxList =
CANONICAL_VAR_INV_GFX[code] ??
(typeof (item.base as any)?.type === 'string'
? CANONICAL_VAR_INV_GFX[(item.base as any).type.trim().toLowerCase()]
: undefined)
// 1. Explicit Unique / Set Table Graphics
if (quality === 'unique') {
@ -592,6 +624,29 @@ export function resolveItemInvFile(
}
}
// 1.5 Variable Inventory Graphics (`ItemTypes.txt` `VarInvGfx` + `InvGfx1..6`)
// Unless a unique/set item has an explicit dedicated `invfile` in UniqueItems.txt (e.g. Annihilus -> invmss, Hellfire Torch -> invtrch),
// rings, amulets, jewels, and charms select their sprite from `InvGfx1..6` via `graphicIndex` (`variant`).
if (!invFile && varGfxList) {
if (quality === 'unique' && item.name === 'Annihilus') {
invFile = 'invmss'
} else if (quality === 'unique' && item.name === 'Hellfire Torch') {
invFile = 'invtrch'
} else if (rawVariant !== undefined) {
invFile = varGfxList[rawVariant % varGfxList.length]
} else {
const explicitInv = ((item as any).invFile ?? (item.base as any)?.invfile ?? (item.base as any)?.invFile ?? '')
.toString()
.trim()
.toLowerCase()
if (explicitInv && (varGfxList.includes(explicitInv) || explicitInv === 'invmss' || explicitInv === 'invtrch')) {
invFile = explicitInv
} else {
invFile = varGfxList[0]
}
}
}
// 2. Pre-baked Manifest Lookup by Item Display Name (handles dyed unique/set sprites e.g. "invcap_cgrn")
if (!invFile && item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name]) {
const manifestInv = BAKED_UI_MANIFEST.codeToInvFile[item.name]
@ -633,10 +688,10 @@ export function resolveItemInvFile(
if (code === 'cm3' && (!invFile || invFile === 'invsst')) invFile = 'invch3'
if ((code === 'rin' || code === 'ring') && (!invFile || invFile === 'invrin')) invFile = 'invrin1'
if ((code === 'amu' || code === 'amul') && (!invFile || invFile === 'invamu')) invFile = 'invamu1'
if ((code === 'jew' || code === 'jewl') && (!invFile || invFile === 'invjew' || invFile === 'invjw')) invFile = 'invjw1'
if ((code === 'jew' || code === 'jewl') && (!invFile || invFile === 'invjew' || invFile === 'invjw' || invFile === 'invgswe')) invFile = 'invjw1'
if (code === 'tbk') invFile = 'invbbk'
if (code === 'ibk') invFile = 'invrbk'
if (code === 'box' || code === 'cube') invFile = 'invbox'
if (code === 'box') invFile = 'invbox'
// 7. Verify 100% Atlas Matching Against BAKED_UI_MANIFEST.itemRects
if (invFile && BAKED_UI_MANIFEST.itemRects[invFile]) {
@ -685,7 +740,7 @@ export function resolveItemInvFile(
export function itemToUiInventoryItem(
item: Item,
dropTables?: DropTables,
): UiInventoryItem {
): UiInventoryItem & { readonly variant?: number | undefined; readonly graphicIndex?: number | undefined } {
// Defensive passthrough: already a complete UiInventoryItem
if (
item &&
@ -1044,6 +1099,12 @@ export function itemToUiInventoryItem(
const slotType =
(rawItem as any).slot ??
(allowedSlots[0] === 'weapon1' ? 'weapon' : allowedSlots[0] ?? 'none')
const itemVariant =
typeof (rawItem as any).variant === 'number' && Number.isFinite((rawItem as any).variant) && (rawItem as any).variant >= 0
? Math.trunc((rawItem as any).variant)
: typeof (rawItem as any).graphicIndex === 'number' && Number.isFinite((rawItem as any).graphicIndex) && (rawItem as any).graphicIndex >= 0
? Math.trunc((rawItem as any).graphicIndex)
: undefined
return {
id,
@ -1062,6 +1123,7 @@ export function itemToUiInventoryItem(
gridH: invHeight,
slotType,
allowedSlots,
...(itemVariant !== undefined ? { variant: itemVariant, graphicIndex: itemVariant } : {}),
...(isTwoHandedWeapon ? { twoHanded: true } : {}),
...(isOneOrTwoHandedWeapon ? { oneOrTwoHanded: true } : {}),
...(!isGambleMystery && tt.defense !== undefined
@ -1496,6 +1558,8 @@ export function onlineItemDataToUiInventoryItem(
readonly nameEn: string
readonly baseName: string
readonly usedSockets: number
readonly variant?: number | undefined
readonly graphicIndex?: number | undefined
readonly damageRange?: { readonly min: number; readonly max: number } | undefined
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
} {
@ -1818,6 +1882,13 @@ export function onlineItemDataToUiInventoryItem(
oneOrTwoHanded: Boolean(baseRow?.oneOrTwoHanded || baseRow?.['1or2handed'] === '1' || baseRow?.['1or2handed'] === 1),
}
const itemVariant =
typeof (item as any).variant === 'number' && Number.isFinite((item as any).variant) && (item as any).variant >= 0
? Math.trunc((item as any).variant)
: typeof (item as any).graphicIndex === 'number' && Number.isFinite((item as any).graphicIndex) && (item as any).graphicIndex >= 0
? Math.trunc((item as any).graphicIndex)
: undefined
const syntheticItem = {
id: String(item.id ?? item.itemId ?? `item-${code}-${++itemBridgeSeqCounter}`),
code,
@ -1855,6 +1926,7 @@ export function onlineItemDataToUiInventoryItem(
socketed: Boolean(item.socketed || totalSockets > 0),
runeword: isRuneword,
},
...(itemVariant !== undefined ? { variant: itemVariant, graphicIndex: itemVariant } : {}),
...(hasDur ? { durability: curDur, maxDurability: maxDur } : {}),
...(totalSockets > 0 ? { sockets: totalSockets, totalSockets, socketedCount: usedSockets } : {}),
...(childDomainItems ? { socketedItems: childDomainItems } : {}),
@ -1924,6 +1996,7 @@ export function onlineItemDataToUiInventoryItem(
baseNameZh,
baseNameEn,
usedSockets,
...(itemVariant !== undefined ? { variant: itemVariant, graphicIndex: itemVariant } : {}),
...(finalDefense !== undefined ? { defense: finalDefense } : {}),
...(totalSockets > 0 ? { sockets: totalSockets } : {}),
...(childUiItems

View File

@ -0,0 +1,503 @@
import { describe, expect, it, vi } from 'vitest'
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
import {
CANONICAL_VAR_INV_GFX,
itemDataToUiInventoryItem,
itemToUiInventoryItem,
onlineItemDataToUiInventoryItem,
resolveItemInvFile,
} from '../../src/common/items/item-bridge.ts'
import { ItemQuality, type Item } from '../../src/common/items/items.ts'
import {
CANONICAL_MISC_USABLE_CODES,
EQUIP_SLOTS_LAYOUT,
GEMSOCKET_SPRITE_RECT,
GRID_ITEM_BG_BLUE,
GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED,
INVENTORY_TXT_LAYOUTS,
INV_GRID_ORIGIN,
INV_PANEL_ORIGIN,
InventoryPanel,
WEAPON_SWAP_TABS_BOUNDS,
drawItemSocketOverlay,
getInventoryLayout,
isUsableRightClickItem,
parseInventoryTxt,
resolveGridItemBackgroundFill,
resolveItemSpriteRect,
resolvePaperdollSlotBackgroundFill,
resolveWeaponSwapTabClick,
type UiInventoryItem,
} from '../../src/client/ui/inventory.ts'
import type { D2FontRenderer } from '../../src/client/ui/font.ts'
function createMockCanvasContext() {
const fillRectCalls: { x: number; y: number; w: number; h: number; fillStyle: string }[] = []
const drawImageCalls: unknown[][] = []
const arcCalls: unknown[][] = []
const textCalls: string[] = []
const ctx = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
fillRectCalls.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) })
}),
strokeRect: vi.fn(),
drawImage: vi.fn((...args: unknown[]) => {
drawImageCalls.push(args)
}),
arc: vi.fn((...args: unknown[]) => {
arcCalls.push(args)
}),
} as unknown as CanvasRenderingContext2D
const font = {
drawText: vi.fn((_ctx: unknown, text: string) => {
textCalls.push(text)
}),
} as unknown as D2FontRenderer
return { ctx, font, fillRectCalls, drawImageCalls, arcCalls, textCalls }
}
describe('Batch 6 Phase 2: Inventory Panel & Item Rendering Parity (Issues #680-#685)', () => {
describe('Issue #680: Right-Click Usable Items Driven by misc.txt useable Column', () => {
const EXPECTED_35_USABLE_CODES = [
'elx', 'hpo', 'mpo', 'hpf', 'mpf', 'vps', 'yps', 'rvs', 'rvl', 'wms',
'tbk', 'ibk', 'tsc', 'isc', 'xyz', 'box', 'ass',
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
'hrb', 'rps', 'rpl', 'bps', 'bpl', '0sc', 'tr2', 'toa',
] as const
it('recognizes all 35 canonical misc.txt useable=1 codes and rejects non-canonical codes like cube', () => {
expect(CANONICAL_MISC_USABLE_CODES.size).toBe(35)
for (const code of EXPECTED_35_USABLE_CODES) {
expect(isUsableRightClickItem(code)).toBe(true)
expect(isUsableRightClickItem(` ${code.toUpperCase()} `)).toBe(true)
}
// Reject non-canonical 'cube' alias and non-usable items
expect(isUsableRightClickItem('cube')).toBe(false)
expect(isUsableRightClickItem('cm1')).toBe(false)
expect(isUsableRightClickItem('rin')).toBe(false)
expect(isUsableRightClickItem('cap')).toBe(false)
expect(isUsableRightClickItem('r01')).toBe(false)
expect(isUsableRightClickItem(undefined)).toBe(false)
})
it('consumes usable potions/scrolls on right-click in InventoryPanel while rejecting non-usable items', () => {
const panel = new InventoryPanel()
panel.visible = true
const usedItems: string[] = []
const hpPotion: UiInventoryItem = {
id: 'pot-1',
code: 'hp4',
invFile: 'invbpot',
name: 'Greater Healing Potion',
nameZh: '强效治疗药剂',
baseNameZh: '强效治疗药剂',
quality: 'normal',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
}
panel.gridItems = [{ item: hpPotion, col: 0, row: 0 }]
const handled = panel.handleClick(
INV_GRID_ORIGIN.x + 5,
INV_GRID_ORIGIN.y + 5,
{
onUseItem: item => {
usedItems.push(item.code)
},
},
2,
)
expect(handled).toBe(true)
expect(usedItems).toEqual(['hp4'])
expect(panel.gridItems.length).toBe(0)
})
})
describe('Issue #681: Item Variant Graphic Index (graphicIndex / InvGfx1..6) without Name Guessing or String Hashing', () => {
const itemRects = BAKED_UI_MANIFEST.itemRects
it('forwards graphicIndex from D2GS ItemData through itemDataToUiInventoryItem to select InvGfx1..6', () => {
// Rings: 5 variants (invrin1..invrin5)
for (let i = 0; i < 5; i++) {
const uiRing = itemDataToUiInventoryItem({
id: 100 + i,
code: 'rin',
quality: 4,
identified: true,
graphicIndex: i,
})
expect(uiRing.variant).toBe(i)
expect(uiRing.graphicIndex).toBe(i)
expect(uiRing.invFile).toBe(`invrin${i + 1}`)
expect(resolveItemSpriteRect(uiRing, itemRects)).toEqual(itemRects[`invrin${i + 1}`])
}
// Amulets: 3 variants (invamu1..invamu3)
for (let i = 0; i < 3; i++) {
const uiAmu = onlineItemDataToUiInventoryItem({
id: 200 + i,
code: 'amu',
quality: 6,
identified: true,
graphicIndex: i,
})
expect(uiAmu.variant).toBe(i)
expect(uiAmu.invFile).toBe(`invamu${i + 1}`)
expect(resolveItemSpriteRect(uiAmu, itemRects)).toEqual(itemRects[`invamu${i + 1}`])
}
// Jewels: 6 variants (invjw1..invjw6), never misc.txt placeholder invgswe
for (let i = 0; i < 6; i++) {
const uiJewel = onlineItemDataToUiInventoryItem({
id: 300 + i,
code: 'jew',
quality: 7,
identified: true,
graphicIndex: i,
})
expect(uiJewel.variant).toBe(i)
expect(uiJewel.invFile).toBe(`invjw${i + 1}`)
expect(resolveItemSpriteRect(uiJewel, itemRects)).toEqual(itemRects[`invjw${i + 1}`])
}
// Charms: cm1 (invch1/4/7), cm2 (invch2/5/8), cm3 (invch3/6/9)
for (let i = 0; i < 3; i++) {
const cm1 = onlineItemDataToUiInventoryItem({ id: 400 + i, code: 'cm1', quality: 4, identified: true, graphicIndex: i })
const cm2 = onlineItemDataToUiInventoryItem({ id: 410 + i, code: 'cm2', quality: 4, identified: true, graphicIndex: i })
const cm3 = onlineItemDataToUiInventoryItem({ id: 420 + i, code: 'cm3', quality: 4, identified: true, graphicIndex: i })
expect(cm1.invFile).toBe(CANONICAL_VAR_INV_GFX.cm1![i])
expect(cm2.invFile).toBe(CANONICAL_VAR_INV_GFX.cm2![i])
expect(cm3.invFile).toBe(CANONICAL_VAR_INV_GFX.cm3![i])
}
})
it('does not hash item.id to pick random ring/amulet/jewel graphics and rejects fake cta code', () => {
// Different item.id strings without variant/graphicIndex must deterministically use index 0
for (const id of ['ring-alpha', 'ring-beta', 'ring-gamma', '99999', '12345']) {
expect(resolveItemSpriteRect({ code: 'rin', id }, itemRects)).toEqual(itemRects['invrin1'])
expect(resolveItemSpriteRect({ code: 'amu', id }, itemRects)).toEqual(itemRects['invamu1'])
expect(resolveItemSpriteRect({ code: 'jew', id }, itemRects)).toEqual(itemRects['invjw1'])
}
// Canonical crs resolves to invcrs, while fake code 'cta' returns null
expect(resolveItemSpriteRect({ code: 'crs' }, itemRects)).toEqual(itemRects['invcrs'])
expect(resolveItemSpriteRect({ code: 'cta' }, itemRects)).toBeNull()
})
it('resolves unique items with dedicated UniqueItems.txt invfile (Annihilus, Hellfire Torch) via table lookup', () => {
const anniDomain = {
id: 1,
code: 'cm1',
name: 'Annihilus',
quality: ItemQuality.UNIQUE,
identified: true,
uniqueItemDef: { invfile: 'invmss', index: 'Annihilus' },
base: { id: 'cm1', name: 'Small Charm', kind: 'misc', invWidth: 1, invHeight: 1 },
} as unknown as Item
expect(resolveItemInvFile(anniDomain, 'unique', 'cm1').invFile).toBe('invmss')
expect(itemToUiInventoryItem(anniDomain).invFile).toBe('invmss')
})
})
describe('Issue #682: Canonical inventory.txt Layout Loading & Coordinates', () => {
it('parses all 32 rows of canonical 1.13c inventory.txt and exposes 640x480 and 800x600 layouts', () => {
expect(INVENTORY_TXT_LAYOUTS.length).toBe(32)
const ama640 = getInventoryLayout('Amazon', 0)
expect(ama640.invLeft).toBe(320)
expect(ama640.invRight).toBe(640)
expect(ama640.invTop).toBe(0)
expect(ama640.invBottom).toBe(441)
expect(ama640.gridLeft).toBe(339)
expect(ama640.gridTop).toBe(257)
const ama800 = getInventoryLayout('ama', 1)
expect(ama800.class).toBe('Amazon2')
expect(ama800.invLeft).toBe(400)
expect(ama800.invRight).toBe(720)
expect(ama800.invTop).toBe(60)
expect(ama800.invBottom).toBe(501)
expect(ama800.invWidth).toBe(320)
expect(ama800.invHeight).toBe(441)
expect(ama800.gridLeft).toBe(419)
expect(ama800.gridTop).toBe(315)
expect(ama800.gridX).toBe(10)
expect(ama800.gridY).toBe(4)
expect(ama800.gridBoxWidth).toBe(29)
expect(ama800.gridBoxHeight).toBe(29)
// Verify INV_PANEL_ORIGIN, INV_GRID_ORIGIN, and all 10 EQUIP_SLOTS_LAYOUT match Amazon2..Assassin2
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
expect(INV_GRID_ORIGIN).toEqual({ x: 419, y: 315, cols: 10, rows: 4, cellPx: 29 })
expect(EQUIP_SLOTS_LAYOUT.helm).toMatchObject({ x: 535, y: 68, w: 54, h: 51 })
expect(EQUIP_SLOTS_LAYOUT.amulet).toMatchObject({ x: 609, y: 95, w: 23, h: 24 })
expect(EQUIP_SLOTS_LAYOUT.weapon1).toMatchObject({ x: 420, y: 107, w: 55, h: 112 })
expect(EQUIP_SLOTS_LAYOUT.armor).toMatchObject({ x: 533, y: 137, w: 56, h: 82 })
expect(EQUIP_SLOTS_LAYOUT.weapon2).toMatchObject({ x: 651, y: 107, w: 55, h: 112 })
expect(EQUIP_SLOTS_LAYOUT.gloves).toMatchObject({ x: 421, y: 241, w: 54, h: 53 })
expect(EQUIP_SLOTS_LAYOUT.ring1).toMatchObject({ x: 495, y: 240, w: 23, h: 24 })
expect(EQUIP_SLOTS_LAYOUT.belt).toMatchObject({ x: 536, y: 239, w: 52, h: 25 })
expect(EQUIP_SLOTS_LAYOUT.ring2).toMatchObject({ x: 609, y: 240, w: 23, h: 24 })
expect(EQUIP_SLOTS_LAYOUT.boots).toMatchObject({ x: 652, y: 242, w: 54, h: 52 })
// Fail-fast on unknown layout or malformed TSV
expect(() => getInventoryLayout('NonExistentPanel', 1)).toThrow()
expect(() => parseInventoryTxt('')).toThrow()
})
})
describe('Issue #683: Weapon Swap I / II Tabs Hitboxes & No Duplicate Text Overlay', () => {
it('splits left and right 62x22 weapon swap tab strips into I (set 0) and II (set 1) sub-tabs', () => {
expect(WEAPON_SWAP_TABS_BOUNDS.left).toEqual({ x: 415, y: 83, w: 62, h: 22 })
expect(WEAPON_SWAP_TABS_BOUNDS.right).toEqual({ x: 645, y: 83, w: 62, h: 22 })
expect(resolveWeaponSwapTabClick(420, 90)).toBe(0) // left I
expect(resolveWeaponSwapTabClick(455, 90)).toBe(1) // left II
expect(resolveWeaponSwapTabClick(650, 90)).toBe(0) // right I
expect(resolveWeaponSwapTabClick(685, 90)).toBe(1) // right II
expect(resolveWeaponSwapTabClick(500, 90)).toBeNull()
const panel = new InventoryPanel()
panel.visible = true
expect(panel.weaponSwapSet).toBe(0)
// Clicking already-active 'I' sub-tab is a no-op
panel.handleClick(420, 90)
expect(panel.weaponSwapSet).toBe(0)
// Clicking 'II' sub-tab switches to set 1
panel.handleClick(455, 90)
expect(panel.weaponSwapSet).toBe(1)
// Clicking 'II' again on right strip is a no-op
panel.handleClick(685, 90)
expect(panel.weaponSwapSet).toBe(1)
// Clicking 'I' on right strip switches back to set 0
panel.handleClick(650, 90)
expect(panel.weaponSwapSet).toBe(0)
})
it('renders invTab0Img / invTab1Img without drawing duplicate Exocet Roman numeral text on top', () => {
const panel = new InventoryPanel()
panel.visible = true
const { ctx, font, textCalls } = createMockCanvasContext()
panel.draw(
ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: {} as HTMLImageElement,
invTab1Img: {} as HTMLImageElement,
buySellBtnImg: null,
itemsAtlasImg: null,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
font,
)
expect(textCalls).not.toContain('I')
expect(textCalls).not.toContain('II')
})
})
describe('Issue #684: Grid & Paperdoll Item Background Colors Follow 1.13c Usability Rules', () => {
const player = { level: 30, str: 60, dex: 60, classCode: 'sor' as const }
const usableUnique: UiInventoryItem = {
id: 'u-1',
code: 'cap',
invFile: 'invcap',
name: 'Biggin\'s Bonnet',
nameZh: '比金的软帽',
baseNameZh: '帽子',
quality: 'unique',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
reqLevel: 3,
stats: [],
}
const unusableArmor: UiInventoryItem = {
id: 'a-1',
code: 'uar',
invFile: 'invful',
name: 'Sacred Armor',
nameZh: '神圣盔甲',
baseNameZh: '神圣盔甲',
quality: 'set',
invWidth: 2,
invHeight: 3,
allowedSlots: ['armor'],
reqStr: 232,
stats: [],
}
it('uses blue for usable grid items regardless of quality, red for unusable/unidentified, and no green fill on normal equipped paperdoll slots', () => {
expect(resolveGridItemBackgroundFill(usableUnique, player)).toBe(GRID_ITEM_BG_BLUE)
expect(resolveGridItemBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
expect(resolveGridItemBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
GRID_ITEM_BG_GREEN,
)
// Paperdoll slots have no fill when usable, red when requirements unmet, green when hovered with cursor item
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player)).toBeNull()
expect(resolvePaperdollSlotBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
GRID_ITEM_BG_GREEN,
)
const panel = new InventoryPanel()
panel.visible = true
panel.playerContext = player
panel.equipped.helm = usableUnique
panel.gridItems = [{ item: usableUnique, col: 0, row: 0 }]
const { ctx, font, fillRectCalls } = createMockCanvasContext()
panel.draw(
ctx,
{
borderRightImg: null,
invSheetImg: {} as HTMLImageElement,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: {} as HTMLImageElement,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
font,
)
// Only the 10x4 grid item draws a background fillRect (in GRID_ITEM_BG_BLUE); the usable equipped helm draws no fillRect
expect(fillRectCalls).toHaveLength(1)
expect(fillRectCalls[0]?.fillStyle).toBe(GRID_ITEM_BG_BLUE)
})
})
describe('Issue #685: Hover-Only Socket Overlays Using gemsocket DC6 Art (Zero Procedural Circles)', () => {
const socketedSword: UiInventoryItem = {
id: 'sw-1',
code: 'crs',
invFile: 'invcrs',
name: 'Crystal Sword',
nameZh: '水晶剑',
baseNameZh: '水晶剑',
quality: 'normal',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1', 'weapon2'],
sockets: 2,
socketedItems: [
{
id: 'r-1',
code: 'r07',
invFile: 'invr07',
name: 'Tal Rune',
nameZh: '塔尔符文',
baseNameZh: '塔尔符文',
quality: 'rune',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
},
],
stats: [],
}
it('draws socket overlays in InventoryPanel ONLY when the item is hovered and never calls ctx.arc', () => {
const panel = new InventoryPanel()
panel.visible = true
panel.gridItems = [{ item: socketedSword, col: 0, row: 0 }]
const mockAtlas = {} as HTMLImageElement
const mockGemSocket = {} as HTMLImageElement
// 1. Not hovered: only the base item sprite is drawn (1 drawImage call)
const unhovered = createMockCanvasContext()
panel.draw(
unhovered.ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: mockAtlas,
gemSocketImg: mockGemSocket,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
unhovered.font,
)
expect(unhovered.arcCalls).toHaveLength(0)
expect(unhovered.drawImageCalls).toHaveLength(1)
// 2. Hovered: base item sprite + 2 gemsocket frames (28x28) + 1 socketed rune sprite = 4 drawImage calls, 0 ctx.arc calls
panel.handleMouseMove(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10)
expect(panel.hoveredItem?.item).toBe(socketedSword)
const hovered = createMockCanvasContext()
panel.draw(
hovered.ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: mockAtlas,
gemSocketImg: mockGemSocket,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
hovered.font,
)
expect(hovered.arcCalls).toHaveLength(0)
expect(hovered.drawImageCalls).toHaveLength(4)
expect(GEMSOCKET_SPRITE_RECT).toEqual({ x: 0, y: 0, w: 28, h: 28 })
})
it('fails fast by throwing when a socketed child item has an unresolvable sprite rect', () => {
const brokenHost: UiInventoryItem = {
...socketedSword,
socketedItems: [
{
id: 'bad-sock',
code: 'nonexistent_socket_code',
invFile: 'nonexistent_invfile',
name: 'Invalid Socket Item',
nameZh: '无效镶嵌物',
baseNameZh: '无效镶嵌物',
quality: 'normal',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
},
],
}
const { ctx } = createMockCanvasContext()
expect(() =>
drawItemSocketOverlay(ctx, brokenHost, 0, 0, 58, 87, {
itemsAtlasImg: {} as HTMLImageElement,
itemRects: BAKED_UI_MANIFEST.itemRects,
}),
).toThrow(/Missing sprite rect for socketed item/)
})
})
})

View File

@ -82,9 +82,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
})
it('enforces D2Client.dll 800x600 panel and control bar coordinates', () => {
// Left Dock (80, 60, 320x432) + Right Dock (400, 60, 320x432)
// Left Dock (80, 60, 320x432) + Right Dock (400, 60, 320x441 per inventory.txt)
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
// Left (117..165) & Right (635..683) 48x48 Skill Slots per D2Client.dll 0x6fad7440..0x6fad7536
@ -458,11 +458,11 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
expect(codeRect).not.toBeNull()
expect(codeRect!.w).toBeGreaterThan(0)
// Tier 4: Canonical 1.13c overrides (e.g. cta -> invcrs, tbk -> invbbk, cube -> invbox)
const ctaItem = { id: 'test4', code: 'cta', name: 'Call to Arms', invFile: 'nonexistent', width: 1, height: 2 }
expect(resolveItemSpriteRect(ctaItem, itemRects)).toEqual(itemRects['invcrs'])
// Tier 4: Canonical 1.13c 3-letter item codes (e.g. crs -> invcrs, tbk -> invbbk, box -> invbox)
const crsItem = { id: 'test4', code: 'crs', name: 'Call to Arms', invFile: 'nonexistent', width: 1, height: 2 }
expect(resolveItemSpriteRect(crsItem, itemRects)).toEqual(itemRects['invcrs'])
const cubeItem = { id: 'test5', code: 'cube', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
const cubeItem = { id: 'test5', code: 'box', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
// Unknown items without valid invFile or canonical code return null (no fake dimension fallback)

View File

@ -177,8 +177,8 @@ describe('Widescreen UI Panels Left/Right Docking & Central Vision Corridor (Iss
describe('Invariant Preservation', () => {
it('preserves canonical panel and grid origin constants unchanged', () => {
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
expect(INV_GRID_ORIGIN).toEqual({ x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 })
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
expect(INV_GRID_ORIGIN).toEqual({ x: 419, y: 315, cols: 10, rows: 4, cellPx: 29 })
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 })
expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 })