From 8f8ea3b30174a3dd63e632dc2a9aac3609ffd858 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 09:45:14 +0000 Subject: [PATCH] fix(netproto): vendor/stash packet encodings per 1.13c disassembly - 0x4F ClickButton: amount high word before low word (D2Game 0x6fcf1470); named ClickButtonId (0x08 trade gold, 0x12 close stash, 0x13 withdraw, 0x14 deposit, 0x18 transmute); amount is required. - 0x35 repair split into NpcRepairItem (itemMode, durability) and NpcRepairAll (field4 0x80000000); no defaults. - 0x32/0x33 field3 semantics: buy = itemMode<<16 | gamble 2 | shift 0x80000000, sell = itemMode; cost required. - 0x38: NpcOpenTrade (action 1) / NpcOpenGamble (action 2), confirmed in D2Game 0x6fce00c0. - 0x2A decoded as NpcTransaction{result@2, flag@1, itemId@7, gold@11} (builder D2Game 0x6fcaac90); ClientWorld no longer writes stat 14 from it. - Byte-level golden tests. --- src/client/ui-model/hud-model.ts | 24 ++-- src/client/world/client-world.ts | 8 +- src/netproto/d2gs/c2s/interact.ts | 37 ++++- src/netproto/d2gs/c2s/npc.ts | 135 ++++++++++++++---- src/netproto/d2gs/c2s/trade.ts | 3 +- src/netproto/d2gs/registry.ts | 22 +-- src/netproto/d2gs/s2c/npc.ts | 30 ++-- src/netproto/domain/client-command.ts | 29 ++-- src/netproto/domain/server-event.ts | 13 +- src/netproto/index.ts | 2 + .../tier5-adversarial-coverage.test.ts | 13 +- tests/netproto/d2gs-codecs-items.test.ts | 11 +- tests/netproto/vendor-stash-golden.test.ts | 135 ++++++++++++++++++ 13 files changed, 380 insertions(+), 82 deletions(-) create mode 100644 tests/netproto/vendor-stash-golden.test.ts diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 2a59fa4..3c9915a 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -13,6 +13,7 @@ import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import { monsterDisplayName } from '../../common/units/monsters-data.ts' import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' +import { ClickButtonId } from '../../netproto/index.ts' import type { CharClassId, ClientCommand, @@ -737,28 +738,35 @@ export class HudModel implements HudCommandSink { * Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`). */ transmuteCube(): void { - this.emitCommand({ type: 'ClickButton', buttonId: 0x18 }) + this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.Transmute, amount: 0 }) } /** * Buy an item from an active NPC vendor (`0x32 NpcBuy`). */ - buyFromNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { - this.emitCommand({ type: 'NpcBuy', npcId, itemId, tab, cost }) + buyFromNpc(p: { npcId: number; itemId: number; itemMode: number; gamble: boolean; shiftFill: boolean; cost: number }): void { + this.emitCommand({ type: 'NpcBuy', ...p }) } /** * Sell an item to an active NPC vendor (`0x33 NpcSell`). */ - sellToNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { - this.emitCommand({ type: 'NpcSell', npcId, itemId, tab, cost }) + sellToNpc(p: { npcId: number; itemId: number; itemMode: number; cost: number }): void { + this.emitCommand({ type: 'NpcSell', ...p }) } /** - * Repair all or a single item at an NPC smith (`0x35 NpcRepair`). + * Repair one item at an NPC smith (`0x35` with itemMode / current durability). */ - repairAtNpc(npcId: number, itemId?: number): void { - this.emitCommand({ type: 'NpcRepair', npcId, itemId }) + repairItemAtNpc(p: { npcId: number; itemId: number; itemMode: number; durability: number }): void { + this.emitCommand({ type: 'NpcRepairItem', ...p }) + } + + /** + * Repair all items at an NPC smith (`0x35` field4 = 0x80000000). + */ + repairAllAtNpc(npcId: number): void { + this.emitCommand({ type: 'NpcRepairAll', npcId }) } /** diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 5927688..38e73db 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -585,9 +585,11 @@ export class ClientWorld { } case 'Npc': { - if (ev.goldRemaining !== undefined) { - this._self.applyStat(14, ev.goldRemaining) - } + break + } + + case 'NpcTransaction': { + // Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]). break } diff --git a/src/netproto/d2gs/c2s/interact.ts b/src/netproto/d2gs/c2s/interact.ts index 5ef7264..6784b68 100644 --- a/src/netproto/d2gs/c2s/interact.ts +++ b/src/netproto/d2gs/c2s/interact.ts @@ -24,12 +24,41 @@ export function encodeC2sWaypointGo(waypointId: number, destAreaId: number): Uin return validateC2sPacket(w.toUint8Array()) } -/** 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 complementLow | u16 complementHigh */ -export function encodeC2sClickButton(buttonId: number, complement = 0): Uint8Array { +/** + * 0x4F ClickButton button ids (1.13c). Sender call sites in D2Client.dll; dispatcher D2Game.dll 0x6fc94200. + * - 0x08 TradeGold: amount = gold offered in the trade window. + * - 0x12 CloseStash: amount 0. + * - 0x13 WithdrawGold: stash -> inventory, amount (D2Game 0x6fc917e0; rejected if gold + amount > clvl*10000). + * - 0x14 DepositGold: inventory -> stash, amount (D2Game 0x6fc917e0; clamped to the 2,500,000 bank cap). + * - 0x18 Transmute: Horadric Cube transmute, amount 0. + */ +export const ClickButtonId = { + TradeGold: 0x08, + CloseStash: 0x12, + WithdrawGold: 0x13, + DepositGold: 0x14, + Transmute: 0x18, +} as const + +/** + * 0x4F ClickButton (7B): u8 0x4F | u16 buttonId | u16 amountHigh | u16 amountLow. + * + * D2Game.dll 0x6fcf1470 rebuilds the 32-bit amount as `(WORD[3] << 16) | WORD[5]`; D2Client's gold + * dialog OK handler (0x6fb6fdf0..0x6fb6fe79) passes the high word in dx and the low word on the stack. + * The high word therefore precedes the low word on the wire. + */ +export function encodeC2sClickButton(buttonId: number, amount: number): Uint8Array { + if (!Number.isInteger(buttonId) || buttonId < 0 || buttonId > 0xffff) { + throw new RangeError(`ClickButton: invalid buttonId ${buttonId}`) + } + if (!Number.isInteger(amount) || amount < 0 || amount > 0xffffffff) { + throw new RangeError(`ClickButton: invalid amount ${amount}`) + } const w = new ByteWriter(7) w.u8(0x4f) - w.u16LE(buttonId & 0xffff) - w.u32LE(complement >>> 0) + w.u16LE(buttonId) + w.u16LE((amount >>> 16) & 0xffff) + w.u16LE(amount & 0xffff) return validateC2sPacket(w.toUint8Array()) } diff --git a/src/netproto/d2gs/c2s/npc.ts b/src/netproto/d2gs/c2s/npc.ts index df168e4..38fe178 100644 --- a/src/netproto/d2gs/c2s/npc.ts +++ b/src/netproto/d2gs/c2s/npc.ts @@ -24,25 +24,69 @@ export function encodeC2sNpcCancel(target: UnitRef): Uint8Array { return validateC2sPacket(w.toUint8Array()) } -/** 0x32 BuyItem (17B): u8 0x32 | u32 npcId | u32 itemId | u32 tab | u32 cost */ -export function encodeC2sNpcBuy(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { +function requireU32(name: string, v: number): number { + if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) throw new RangeError(`${name}: invalid u32 ${v}`) + return v >>> 0 +} + +function requireU16(name: string, v: number): number { + if (!Number.isInteger(v) || v < 0 || v > 0xffff) throw new RangeError(`${name}: invalid u16 ${v}`) + return v +} + +/** 0x32 field3 bit: gamble purchase (D2Client global [0x6fbc97a8] set while the gamble window is open). */ +export const NPC_BUY_FLAG_GAMBLE = 0x00000002 +/** 0x32 field3 bit: shift+right-click fill buy (ItemsTxt +0x1a5 quantity-fill items; D2Client 0x6faf6ee0). */ +export const NPC_BUY_FLAG_SHIFT_FILL = 0x80000000 + +export interface NpcBuyParams { + readonly npcId: number + readonly itemId: number + /** Shop item UnitAny.dwMode (u16) as seen by the client; packed into the high word of field3. */ + readonly itemMode: number + readonly gamble: boolean + readonly shiftFill: boolean + /** Client-side cost (D2Common #10107 type 0/2); the server recomputes the price itself. */ + readonly cost: number +} + +/** + * 0x32 BuyItem (17B): u8 0x32 | u32 npcId | u32 itemId | u32 field3 | u32 cost. + * field3 = (itemMode << 16) | (gamble ? 2 : 0) | (shiftFill ? 0x80000000 : 0) (D2Client 0x6faf6ee0; + * D2Game 0x6fcef220 splits `&0x80000000`, `&0x7fffffff`, `>>16` before calling 0x6fcdf200). + */ +export function encodeC2sNpcBuy(p: NpcBuyParams): Uint8Array { + const field3 = + ((requireU16('NpcBuy.itemMode', p.itemMode) << 16) | + (p.gamble ? NPC_BUY_FLAG_GAMBLE : 0) | + (p.shiftFill ? NPC_BUY_FLAG_SHIFT_FILL : 0)) >>> + 0 const w = new ByteWriter(17) w.u8(0x32) - w.u32LE(npcId >>> 0) - w.u32LE(itemId >>> 0) - w.u32LE(tab >>> 0) - w.u32LE(cost >>> 0) + w.u32LE(requireU32('NpcBuy.npcId', p.npcId)) + w.u32LE(requireU32('NpcBuy.itemId', p.itemId)) + w.u32LE(field3) + w.u32LE(requireU32('NpcBuy.cost', p.cost)) return validateC2sPacket(w.toUint8Array()) } -/** 0x33 SellItem (17B): u8 0x33 | u32 npcId | u32 itemId | u32 tab | u32 cost */ -export function encodeC2sNpcSell(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { +export interface NpcSellParams { + readonly npcId: number + readonly itemId: number + /** Sold item UnitAny.dwMode (u16); D2Game only reads WORD@9 of field3. */ + readonly itemMode: number + /** Client-side sell price (D2Common #10107 type 1); the server recomputes the price itself. */ + readonly cost: number +} + +/** 0x33 SellItem (17B): u8 0x33 | u32 npcId | u32 itemId | u32 field3 (= itemMode) | u32 cost (D2Client 0x6fb46d5f). */ +export function encodeC2sNpcSell(p: NpcSellParams): Uint8Array { const w = new ByteWriter(17) w.u8(0x33) - w.u32LE(npcId >>> 0) - w.u32LE(itemId >>> 0) - w.u32LE(tab >>> 0) - w.u32LE(cost >>> 0) + w.u32LE(requireU32('NpcSell.npcId', p.npcId)) + w.u32LE(requireU32('NpcSell.itemId', p.itemId)) + w.u32LE(requireU16('NpcSell.itemMode', p.itemMode)) + w.u32LE(requireU32('NpcSell.cost', p.cost)) return validateC2sPacket(w.toUint8Array()) } @@ -54,19 +98,41 @@ export function encodeC2sNpcIdentifyAll(npcId: number): Uint8Array { return validateC2sPacket(w.toUint8Array()) } -/** 0x35 Repair (17B): u8 0x35 | u32 npcId | u32 itemId | u32 tab | u32 cost */ -export function encodeC2sNpcRepair( - npcId: number, - itemId = 0, - tab = 0, - cost = 0x80000000, -): Uint8Array { +/** 0x35 field4 sign bit: repair every equipped/inventory item (D2Game 0x6fcdde60, price D2Common #10071). */ +export const NPC_REPAIR_ALL_FLAG = 0x80000000 + +export interface NpcRepairItemParams { + readonly npcId: number + readonly itemId: number + /** Item UnitAny.dwMode (u16). */ + readonly itemMode: number + /** Current durability (item stat 72) as read by the client via D2Common #10973. */ + readonly durability: number +} + +/** 0x35 Repair single item (17B): u8 0x35 | u32 npcId | u32 itemId | u32 itemMode | u32 durability. */ +export function encodeC2sNpcRepairItem(p: NpcRepairItemParams): Uint8Array { + const durability = requireU32('NpcRepairItem.durability', p.durability) + if ((durability & NPC_REPAIR_ALL_FLAG) !== 0) { + throw new RangeError('NpcRepairItem: durability must not set the repair-all sign bit') + } const w = new ByteWriter(17) w.u8(0x35) - w.u32LE(npcId >>> 0) - w.u32LE(itemId >>> 0) - w.u32LE(tab >>> 0) - w.u32LE(cost >>> 0) + w.u32LE(requireU32('NpcRepairItem.npcId', p.npcId)) + w.u32LE(requireU32('NpcRepairItem.itemId', p.itemId)) + w.u32LE(requireU16('NpcRepairItem.itemMode', p.itemMode)) + w.u32LE(durability) + return validateC2sPacket(w.toUint8Array()) +} + +/** 0x35 Repair all (17B): u8 0x35 | u32 npcId | u32 0 | u32 0 | u32 0x80000000 (D2Client 0x6faf7dff clears itemId). */ +export function encodeC2sNpcRepairAll(npcId: number): Uint8Array { + const w = new ByteWriter(17) + w.u8(0x35) + w.u32LE(requireU32('NpcRepairAll.npcId', npcId)) + w.u32LE(0) + w.u32LE(0) + w.u32LE(NPC_REPAIR_ALL_FLAG) return validateC2sPacket(w.toUint8Array()) } @@ -79,13 +145,26 @@ export function encodeC2sHireMerc(npcId: number, mercId: number): Uint8Array { return validateC2sPacket(w.toUint8Array()) } -/** 0x38 EntityAction (13B): u8 0x38 | u32 actionType (1=trade, 2=gamble) | u32 npcId | u32 complement */ -export function encodeC2sNpcEntityAction(actionType: number, npcId: number, complement = 0): Uint8Array { +/** + * 0x38 EntityAction action values handled by D2Game.dll 0x6fce00c0 (via C2S handler 0x6fcefb30): + * - 1 Trade: vendor classes -> 0x6fcdf810(gamble = 0) fills the shop. + * - 2 Gamble: classes 147/199/254/405/512/514 -> 0x6fcdf810(gamble = 1). + * - 3 Hire: 0x6fcdd330 (mercenary list). + * D2Client sends both trade (0x6fafb1e6) and gamble (0x6fafae85) with complement 0. + */ +export const NpcActionId = { + Trade: 1, + Gamble: 2, + Hire: 3, +} as const + +/** 0x38 EntityAction (13B): u8 0x38 | u32 action | u32 npcId | u32 complement */ +export function encodeC2sNpcEntityAction(action: number, npcId: number, complement: number): Uint8Array { const w = new ByteWriter(13) w.u8(0x38) - w.u32LE(actionType >>> 0) - w.u32LE(npcId >>> 0) - w.u32LE(complement >>> 0) + w.u32LE(requireU32('NpcEntityAction.action', action)) + w.u32LE(requireU32('NpcEntityAction.npcId', npcId)) + w.u32LE(requireU32('NpcEntityAction.complement', complement)) return validateC2sPacket(w.toUint8Array()) } diff --git a/src/netproto/d2gs/c2s/trade.ts b/src/netproto/d2gs/c2s/trade.ts index 405239a..dcafbe0 100644 --- a/src/netproto/d2gs/c2s/trade.ts +++ b/src/netproto/d2gs/c2s/trade.ts @@ -4,6 +4,7 @@ import { encodeC2sClickButton } from './interact.ts' -export function encodeC2sTrade(action: number, gold = 0): Uint8Array { +/** Trade window buttons share 0x4F ClickButton; `gold` is only meaningful for button 0x08 (TradeGold). */ +export function encodeC2sTrade(action: number, gold: number): Uint8Array { return encodeC2sClickButton(action, gold) } diff --git a/src/netproto/d2gs/registry.ts b/src/netproto/d2gs/registry.ts index 1c743fe..af7c72d 100644 --- a/src/netproto/d2gs/registry.ts +++ b/src/netproto/d2gs/registry.ts @@ -40,7 +40,9 @@ import { encodeC2sNpcEntityAction, encodeC2sNpcIdentifyAll, encodeC2sNpcInit, - encodeC2sNpcRepair, + encodeC2sNpcRepairAll, + encodeC2sNpcRepairItem, + NpcActionId, encodeC2sNpcResurrectMerc, encodeC2sNpcSell, } from './c2s/npc.ts' @@ -468,16 +470,20 @@ export function encodeD2gsClientCommand( return [encodeC2sNpcInit(cmd.target)] case 'NpcCancel': return [encodeC2sNpcCancel(cmd.target)] + case 'NpcOpenTrade': + return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)] + case 'NpcOpenGamble': + return [encodeC2sNpcEntityAction(NpcActionId.Gamble, cmd.npcId, 0)] case 'NpcBuy': - return [encodeC2sNpcBuy(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] + return [encodeC2sNpcBuy(cmd)] case 'NpcSell': - return [encodeC2sNpcSell(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] - case 'NpcRepair': - return [encodeC2sNpcRepair(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] + return [encodeC2sNpcSell(cmd)] + case 'NpcRepairItem': + return [encodeC2sNpcRepairItem(cmd)] + case 'NpcRepairAll': + return [encodeC2sNpcRepairAll(cmd.npcId)] case 'NpcHeal': return [encodeC2sNpcInit({ unitType: 1, id: cmd.npcId })] - case 'NpcGamble': - return [encodeC2sNpcEntityAction(2, cmd.npcId, cmd.itemId ?? 0)] case 'NpcIdentifyAll': return [encodeC2sNpcIdentifyAll(cmd.npcId)] case 'NpcResurrectMerc': @@ -493,7 +499,7 @@ export function encodeD2gsClientCommand( case 'TownPortal': return [encodeC2sSkillAt('right', { x: 0, y: 0 })] case 'ClickButton': - return [encodeC2sClickButton(cmd.buttonId, cmd.complement)] + return [encodeC2sClickButton(cmd.buttonId, cmd.amount)] case 'Party': return [encodeC2sParty(cmd.action, cmd.targetPlayerId)] case 'Hostile': diff --git a/src/netproto/d2gs/s2c/npc.ts b/src/netproto/d2gs/s2c/npc.ts index cd4083e..a2ffd8c 100644 --- a/src/netproto/d2gs/s2c/npc.ts +++ b/src/netproto/d2gs/s2c/npc.ts @@ -24,22 +24,30 @@ export function decode0x27NpcInfo(pkt: Uint8Array): ServerEvent { } } -/** 0x2A NPCTransaction (15B, D2Client.dll 0x6fb5f9b0) */ +/** + * 0x2A NPCTransaction (15B). + * + * Server builder D2Game.dll 0x6fcaac90(al=0x2A, cl=result, edx=gold; stack: client, itemId, flag): + * [0]=0x2A [1]=flag [2]=result [3..6]=not written (stack garbage) [7..10]=u32 itemId [11..14]=u32 gold (stat 14). + * Client D2Client.dll 0x6fb5f9b0 -> 0x6fafbc80 copies the packet to 0x6fbc977f and only reads + * result ([0x6fbc9781]) and itemId ([0x6fbc9786]); flag and gold are not consumed by the client + * (inventory gold is driven by stat 14 updates). + */ export function decode0x2ANpcTransaction(pkt: Uint8Array): ServerEvent { + if (pkt.length !== 15) throw new RangeError(`0x2A NPCTransaction: expected 15 bytes, got ${pkt.length}`) const r = new ByteReader(pkt) r.u8() // 0x2A - const tradeType = r.u8() + const flag = r.u8() const result = r.u8() - const npcId = r.u32LE() - const merchandiseId = r.u32LE() - const goldRemaining = r.u32LE() + r.skip(4) // [3..6] uninitialised on the server side + const itemId = r.u32LE() + const gold = r.u32LE() return { - type: 'Npc', - kind: 'transaction', - npcId, - result: (result << 8) | tradeType, - merchandiseId, - goldRemaining, + type: 'NpcTransaction', + result, + flag, + itemId, + gold, } } diff --git a/src/netproto/domain/client-command.ts b/src/netproto/domain/client-command.ts index 80c65a5..87c009f 100644 --- a/src/netproto/domain/client-command.ts +++ b/src/netproto/domain/client-command.ts @@ -50,29 +50,39 @@ export type ClientCommand = | { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number } | { readonly type: 'NpcInit'; readonly target: UnitRef } | { readonly type: 'NpcCancel'; readonly target: UnitRef } + /** 0x38 action 1: open the vendor trade window (server fills the shop with 0x9C AddToShop). */ + | { readonly type: 'NpcOpenTrade'; readonly npcId: number } + /** 0x38 action 2: open the gamble window. */ + | { readonly type: 'NpcOpenGamble'; readonly npcId: number } | { + /** 0x32 BuyItem; field3 = (itemMode << 16) | (gamble ? 2 : 0) | (shiftFill ? 0x80000000 : 0). */ readonly type: 'NpcBuy' readonly npcId: number readonly itemId: number - readonly tab: number + readonly itemMode: number + readonly gamble: boolean + readonly shiftFill: boolean readonly cost: number } | { + /** 0x33 SellItem; field3 = itemMode. */ readonly type: 'NpcSell' readonly npcId: number readonly itemId: number - readonly tab: number + readonly itemMode: number readonly cost: number } | { - readonly type: 'NpcRepair' + /** 0x35 Repair one item: field3 = itemMode, field4 = current durability (stat 72). */ + readonly type: 'NpcRepairItem' readonly npcId: number - readonly itemId?: number | undefined - readonly tab?: number | undefined - readonly cost?: number | undefined + readonly itemId: number + readonly itemMode: number + readonly durability: number } + /** 0x35 Repair all: itemId 0, field3 0, field4 0x80000000. */ + | { readonly type: 'NpcRepairAll'; readonly npcId: number } | { readonly type: 'NpcHeal'; readonly npcId: number } - | { readonly type: 'NpcGamble'; readonly npcId: number; readonly itemId?: number | undefined } | { readonly type: 'NpcIdentifyAll'; readonly npcId: number } | { readonly type: 'NpcResurrectMerc'; readonly npcId: number } | { readonly type: 'HireMerc'; readonly npcId: number; readonly mercId: number } @@ -80,10 +90,11 @@ export type ClientCommand = | { readonly type: 'AddSkillPoint'; readonly skillId: number } | { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number } | { readonly type: 'TownPortal' } - | { readonly type: 'ClickButton'; readonly buttonId: number; readonly complement?: number | undefined } + /** 0x4F ClickButton: see `ClickButtonId` (0x12 close stash, 0x13 withdraw, 0x14 deposit, 0x18 transmute). */ + | { readonly type: 'ClickButton'; readonly buttonId: number; readonly amount: number } | { readonly type: 'Party'; readonly action: number; readonly targetPlayerId: number } | { readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean } - | { readonly type: 'Trade'; readonly action: number; readonly gold?: number | undefined } + | { readonly type: 'Trade'; readonly action: number; readonly gold: number } | { readonly type: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined } | { readonly type: 'OverheadChat'; readonly text: string } | { readonly type: 'Resurrect' } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index e0e9842..7c53e89 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -141,13 +141,20 @@ export type ServerEvent = } | { readonly type: 'Npc' - readonly kind: 'info' | 'menu' | 'transaction' | 'heal' + readonly kind: 'info' | 'menu' | 'heal' readonly npcId: number readonly result?: number | undefined - readonly merchandiseId?: number | undefined - readonly goldRemaining?: number | undefined readonly raw?: Uint8Array | undefined } + | { + /** 0x2A NPCTransaction result (D2Game 0x6fcaac90). `itemId` 0xFFFFFFFF = none. */ + readonly type: 'NpcTransaction' + readonly result: number + readonly flag: number + readonly itemId: number + /** Server's stat 14 at send time; informational only (the client uses stat 14 updates). */ + readonly gold: number + } | { readonly type: 'WaypointMenu' readonly unitId: number diff --git a/src/netproto/index.ts b/src/netproto/index.ts index 884c885..1b4a089 100644 --- a/src/netproto/index.ts +++ b/src/netproto/index.ts @@ -113,6 +113,8 @@ export { decodeBncsS2cPacket } from './bncs/packets.ts' export { McpFramer } from './mcp/framing.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts' export { validateC2sPacket } from './d2gs/framing-c2s.ts' +export { ClickButtonId } from './d2gs/c2s/interact.ts' +export { NpcActionId, NPC_BUY_FLAG_GAMBLE, NPC_BUY_FLAG_SHIFT_FILL, NPC_REPAIR_ALL_FLAG } from './d2gs/c2s/npc.ts' export { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts' export { D2GS_S2C_REGISTRY, diff --git a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts index 4a73c4f..7bdc5d5 100644 --- a/tests/e2e-netproto/tier5-adversarial-coverage.test.ts +++ b/tests/e2e-netproto/tier5-adversarial-coverage.test.ts @@ -1213,9 +1213,16 @@ describe('Tier 5 Adversarial Coverage — F5–F8 D2GS Framing, Huffman, Tables { cmd: { type: 'Interact', target: { unitType: 'object', id: asUnitId(100) } }, expectedOpcodes: [0x13] }, { cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] }, { cmd: { type: 'NpcCancel', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x30] }, - { cmd: { type: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), tab: 0, cost: 500 }, expectedOpcodes: [0x32] }, - { cmd: { type: 'NpcSell', npcId: asUnitId(200), itemId: asUnitId(301), tab: 0, cost: 250 }, expectedOpcodes: [0x33] }, - { cmd: { type: 'NpcRepair', npcId: asUnitId(200), itemId: asUnitId(302), tab: 0, cost: 100 }, expectedOpcodes: [0x35] }, + { + cmd: { type: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), itemMode: 0, gamble: false, shiftFill: false, cost: 500 }, + expectedOpcodes: [0x32], + }, + { cmd: { type: 'NpcSell', npcId: asUnitId(200), itemId: asUnitId(301), itemMode: 0, cost: 250 }, expectedOpcodes: [0x33] }, + { + cmd: { type: 'NpcRepairItem', npcId: asUnitId(200), itemId: asUnitId(302), itemMode: 1, durability: 10 }, + expectedOpcodes: [0x35], + }, + { cmd: { type: 'NpcRepairAll', npcId: asUnitId(200) }, expectedOpcodes: [0x35] }, { cmd: { type: 'WaypointGo', waypointId: asUnitId(400), destAreaId: 3 }, expectedOpcodes: [0x49] }, { cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: false }, expectedOpcodes: [0x16] }, { cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] }, diff --git a/tests/netproto/d2gs-codecs-items.test.ts b/tests/netproto/d2gs-codecs-items.test.ts index aa51ded..c33573b 100644 --- a/tests/netproto/d2gs-codecs-items.test.ts +++ b/tests/netproto/d2gs-codecs-items.test.ts @@ -144,13 +144,16 @@ describe('D2GS 1.13c extracted tables, Huffman codec, S2C/C2S framing & registry { type: 'IdentifyItem', itemId: 107, scrollId: 108 }, { type: 'NpcInit', target: { unitType: 1, id: 15 } }, { type: 'NpcCancel', target: { unitType: 1, id: 15 } }, - { type: 'NpcBuy', npcId: 15, itemId: 200, tab: 0, cost: 50 }, - { type: 'NpcSell', npcId: 15, itemId: 200, tab: 0, cost: 25 }, - { type: 'NpcRepair', npcId: 15, itemId: 0, tab: 0, cost: 0 }, + { type: 'NpcOpenTrade', npcId: 15 }, + { type: 'NpcOpenGamble', npcId: 15 }, + { type: 'NpcBuy', npcId: 15, itemId: 200, itemMode: 0, gamble: false, shiftFill: false, cost: 50 }, + { type: 'NpcSell', npcId: 15, itemId: 200, itemMode: 0, cost: 25 }, + { type: 'NpcRepairItem', npcId: 15, itemId: 200, itemMode: 1, durability: 3 }, + { type: 'NpcRepairAll', npcId: 15 }, { type: 'AddStatPoint', statId: 0, count: 1 }, { type: 'AddSkillPoint', skillId: 36 }, { type: 'WaypointGo', waypointId: 30, destAreaId: 1 }, - { type: 'ClickButton', buttonId: 0, complement: 0 }, + { type: 'ClickButton', buttonId: 0x12, amount: 0 }, { type: 'Party', action: 6, targetPlayerId: 2 }, { type: 'Hostile', targetPlayerId: 2, hostile: true }, { type: 'Trade', action: 2, gold: 0 }, diff --git a/tests/netproto/vendor-stash-golden.test.ts b/tests/netproto/vendor-stash-golden.test.ts new file mode 100644 index 0000000..3e06d93 --- /dev/null +++ b/tests/netproto/vendor-stash-golden.test.ts @@ -0,0 +1,135 @@ +/** + * Byte-level golden tests for NPC vendor / stash gold packets (1.13c). + * + * Layouts are taken from the disassembly (see each encoder/decoder's doc comment): + * - 0x4F ClickButton: D2Game 0x6fcf1470 amount = (WORD[3] << 16) | WORD[5]. + * - 0x32 / 0x33 / 0x35: D2Client 0x6faf6ee0 / 0x6fb46d5f / 0x6faf7dff, D2Game 0x6fcef220 / 0x6fcef0a0. + * - 0x38: D2Game 0x6fce00c0 action 1 = trade, 2 = gamble. + * - 0x2A: D2Game 0x6fcaac90 builder; D2Client 0x6fafbc80 reader. + */ + +import { describe, expect, it } from 'vitest' +import { decode0x2ANpcTransaction } from '../../src/netproto/d2gs/s2c/npc.ts' +import { encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts' +import { ClickButtonId } from '../../src/netproto/d2gs/c2s/interact.ts' + +const bytes = (cmd: Parameters[0]): number[] => { + const pkts = encodeD2gsClientCommand(cmd) + expect(pkts).toHaveLength(1) + return Array.from(pkts[0]!) +} + +describe('C2S 0x4F ClickButton (amount high word first)', () => { + it('deposit 0x00012345 gold -> 4F 14 00 | 01 00 | 45 23', () => { + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount: 0x00012345 })).toEqual([ + 0x4f, 0x14, 0x00, 0x01, 0x00, 0x45, 0x23, + ]) + }) + + it('withdraw 1 gold -> 4F 13 00 | 00 00 | 01 00', () => { + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount: 1 })).toEqual([ + 0x4f, 0x13, 0x00, 0x00, 0x00, 0x01, 0x00, + ]) + }) + + it('close stash / transmute carry amount 0', () => { + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 })).toEqual([ + 0x4f, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + expect(bytes({ type: 'ClickButton', buttonId: ClickButtonId.Transmute, amount: 0 })).toEqual([ + 0x4f, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + }) + + it('trade gold reuses the same hi/lo layout (button 0x08)', () => { + expect(bytes({ type: 'Trade', action: ClickButtonId.TradeGold, gold: 2_500_000 })).toEqual([ + // 2_500_000 = 0x002625A0 + 0x4f, 0x08, 0x00, 0x26, 0x00, 0xa0, 0x25, + ]) + }) + + it('rejects non-integer / out-of-range amounts', () => { + expect(() => bytes({ type: 'ClickButton', buttonId: 0x14, amount: -1 })).toThrow(RangeError) + expect(() => bytes({ type: 'ClickButton', buttonId: 0x14, amount: 1.5 })).toThrow(RangeError) + expect(() => bytes({ type: 'ClickButton', buttonId: 0x10000, amount: 0 })).toThrow(RangeError) + }) +}) + +describe('C2S 0x38 NpcEntityAction', () => { + it('trade = action 1, complement 0', () => { + expect(bytes({ type: 'NpcOpenTrade', npcId: 7 })).toEqual([ + 0x38, 0x01, 0, 0, 0, 0x07, 0, 0, 0, 0, 0, 0, 0, + ]) + }) + it('gamble = action 2, complement 0', () => { + expect(bytes({ type: 'NpcOpenGamble', npcId: 0x0102 })).toEqual([ + 0x38, 0x02, 0, 0, 0, 0x02, 0x01, 0, 0, 0, 0, 0, 0, + ]) + }) +}) + +describe('C2S 0x32 NpcBuy field3', () => { + it('plain buy: field3 = itemMode << 16', () => { + expect( + bytes({ type: 'NpcBuy', npcId: 7, itemId: 0x20, itemMode: 0, gamble: false, shiftFill: false, cost: 25 }), + ).toEqual([0x32, 7, 0, 0, 0, 0x20, 0, 0, 0, 0, 0, 0, 0, 25, 0, 0, 0]) + expect( + bytes({ type: 'NpcBuy', npcId: 7, itemId: 0x20, itemMode: 0x0004, gamble: false, shiftFill: false, cost: 25 }), + ).toEqual([0x32, 7, 0, 0, 0, 0x20, 0, 0, 0, 0x00, 0x00, 0x04, 0x00, 25, 0, 0, 0]) + }) + it('gamble sets bit 1, shift-fill sets bit 31', () => { + expect( + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0, gamble: true, shiftFill: false, cost: 0x1234 }), + ).toEqual([0x32, 1, 0, 0, 0, 2, 0, 0, 0, 0x02, 0, 0, 0, 0x34, 0x12, 0, 0]) + expect( + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0, gamble: false, shiftFill: true, cost: 1 }), + ).toEqual([0x32, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0x80, 1, 0, 0, 0]) + }) + it('requires a u16 itemMode and u32 cost', () => { + expect(() => + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0x10000, gamble: false, shiftFill: false, cost: 1 }), + ).toThrow(RangeError) + expect(() => + bytes({ type: 'NpcBuy', npcId: 1, itemId: 2, itemMode: 0, gamble: false, shiftFill: false, cost: Number.NaN }), + ).toThrow(RangeError) + }) +}) + +describe('C2S 0x33 NpcSell', () => { + it('field3 = itemMode', () => { + expect(bytes({ type: 'NpcSell', npcId: 7, itemId: 0x31, itemMode: 0, cost: 12 })).toEqual([ + 0x33, 7, 0, 0, 0, 0x31, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, + ]) + }) +}) + +describe('C2S 0x35 Repair', () => { + it('single item: itemId, itemMode, current durability (never the repair-all bit)', () => { + expect(bytes({ type: 'NpcRepairItem', npcId: 7, itemId: 0x40, itemMode: 1, durability: 13 })).toEqual([ + 0x35, 7, 0, 0, 0, 0x40, 0, 0, 0, 1, 0, 0, 0, 13, 0, 0, 0, + ]) + expect(() => + bytes({ type: 'NpcRepairItem', npcId: 7, itemId: 0x40, itemMode: 1, durability: 0x80000000 }), + ).toThrow(RangeError) + }) + it('repair all: itemId 0, field3 0, field4 0x80000000', () => { + expect(bytes({ type: 'NpcRepairAll', npcId: 7 })).toEqual([ + 0x35, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x80, + ]) + }) +}) + +describe('S2C 0x2A NPCTransaction', () => { + it('decodes flag@1, result@2, itemId@7, gold@11 and ignores the uninitialised bytes 3..6', () => { + const pkt = new Uint8Array([ + 0x2a, 0x01, 0x02, 0xde, 0xad, 0xbe, 0xef, 0xff, 0xff, 0xff, 0xff, 0x39, 0x30, 0x00, 0x00, + ]) + expect(decode0x2ANpcTransaction(pkt)).toEqual({ + type: 'NpcTransaction', + result: 2, + flag: 1, + itemId: 0xffffffff, + gold: 12345, + }) + }) +})