From 04bfa1a1570674d29398ecfd06f24dcc23fac3b9 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 3 Oct 2026 00:39:52 +0000 Subject: [PATCH] Fix inventory.txt layout, item graphicIndex, misc.txt usables, weapon swap tabs, slot colors, and socket overlays (Closes #680, #681, #682, #683, #684, #685) --- src/client/ui/inventory.ts | 711 +++++++++++++++++++------- src/common/items/item-bridge.ts | 79 ++- tests/client/inventory-batch6.test.ts | 503 ++++++++++++++++++ tests/ui-hud.test.ts | 12 +- tests/widescreen-docking.test.ts | 4 +- 5 files changed, 1099 insertions(+), 210 deletions(-) create mode 100644 tests/client/inventory-batch6.test.ts diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index f92d951..8830807 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -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> +} + +/** + * 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() + 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 + 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 = new Map( + INVENTORY_TXT_LAYOUTS.map(r => [r.class, r]), +) + +const CLASS_CODE_TO_INVENTORY_TXT_NAME: Readonly> = { + 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 > = { - 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 = 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 }, + 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 }, @@ -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 }, + 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 +} diff --git a/src/common/items/item-bridge.ts b/src/common/items/item-bridge.ts index 86cccdf..51d7b2e 100644 --- a/src/common/items/item-bridge.ts +++ b/src/common/items/item-bridge.ts @@ -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> = { + 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 diff --git a/tests/client/inventory-batch6.test.ts b/tests/client/inventory-batch6.test.ts new file mode 100644 index 0000000..5c22bc9 --- /dev/null +++ b/tests/client/inventory-batch6.test.ts @@ -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/) + }) + }) +}) diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 41a0ada..38a7cbf 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -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) diff --git a/tests/widescreen-docking.test.ts b/tests/widescreen-docking.test.ts index 95f4abd..c0a17dd 100644 --- a/tests/widescreen-docking.test.ts +++ b/tests/widescreen-docking.test.ts @@ -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 })