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.
This commit is contained in:
troytt 2026-10-01 09:45:14 +00:00
parent 3c1ed16bf9
commit 8f8ea3b301
13 changed files with 380 additions and 82 deletions

View File

@ -13,6 +13,7 @@ import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import { monsterDisplayName } from '../../common/units/monsters-data.ts' import { monsterDisplayName } from '../../common/units/monsters-data.ts'
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
import { ClickButtonId } from '../../netproto/index.ts'
import type { import type {
CharClassId, CharClassId,
ClientCommand, ClientCommand,
@ -737,28 +738,35 @@ export class HudModel implements HudCommandSink {
* Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`). * Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`).
*/ */
transmuteCube(): void { 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`). * Buy an item from an active NPC vendor (`0x32 NpcBuy`).
*/ */
buyFromNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { buyFromNpc(p: { npcId: number; itemId: number; itemMode: number; gamble: boolean; shiftFill: boolean; cost: number }): void {
this.emitCommand({ type: 'NpcBuy', npcId, itemId, tab, cost }) this.emitCommand({ type: 'NpcBuy', ...p })
} }
/** /**
* Sell an item to an active NPC vendor (`0x33 NpcSell`). * Sell an item to an active NPC vendor (`0x33 NpcSell`).
*/ */
sellToNpc(npcId: number, itemId: number, tab = 0, cost = 0): void { sellToNpc(p: { npcId: number; itemId: number; itemMode: number; cost: number }): void {
this.emitCommand({ type: 'NpcSell', npcId, itemId, tab, cost }) 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 { repairItemAtNpc(p: { npcId: number; itemId: number; itemMode: number; durability: number }): void {
this.emitCommand({ type: 'NpcRepair', npcId, itemId }) this.emitCommand({ type: 'NpcRepairItem', ...p })
}
/**
* Repair all items at an NPC smith (`0x35` field4 = 0x80000000).
*/
repairAllAtNpc(npcId: number): void {
this.emitCommand({ type: 'NpcRepairAll', npcId })
} }
/** /**

View File

@ -585,9 +585,11 @@ export class ClientWorld {
} }
case 'Npc': { case 'Npc': {
if (ev.goldRemaining !== undefined) { break
this._self.applyStat(14, ev.goldRemaining) }
}
case 'NpcTransaction': {
// Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]).
break break
} }

View File

@ -24,12 +24,41 @@ export function encodeC2sWaypointGo(waypointId: number, destAreaId: number): Uin
return validateC2sPacket(w.toUint8Array()) 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) const w = new ByteWriter(7)
w.u8(0x4f) w.u8(0x4f)
w.u16LE(buttonId & 0xffff) w.u16LE(buttonId)
w.u32LE(complement >>> 0) w.u16LE((amount >>> 16) & 0xffff)
w.u16LE(amount & 0xffff)
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }

View File

@ -24,25 +24,69 @@ export function encodeC2sNpcCancel(target: UnitRef): Uint8Array {
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x32 BuyItem (17B): u8 0x32 | u32 npcId | u32 itemId | u32 tab | u32 cost */ function requireU32(name: string, v: number): number {
export function encodeC2sNpcBuy(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { 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) const w = new ByteWriter(17)
w.u8(0x32) w.u8(0x32)
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcBuy.npcId', p.npcId))
w.u32LE(itemId >>> 0) w.u32LE(requireU32('NpcBuy.itemId', p.itemId))
w.u32LE(tab >>> 0) w.u32LE(field3)
w.u32LE(cost >>> 0) w.u32LE(requireU32('NpcBuy.cost', p.cost))
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x33 SellItem (17B): u8 0x33 | u32 npcId | u32 itemId | u32 tab | u32 cost */ export interface NpcSellParams {
export function encodeC2sNpcSell(npcId: number, itemId: number, tab: number, cost: number): Uint8Array { 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) const w = new ByteWriter(17)
w.u8(0x33) w.u8(0x33)
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcSell.npcId', p.npcId))
w.u32LE(itemId >>> 0) w.u32LE(requireU32('NpcSell.itemId', p.itemId))
w.u32LE(tab >>> 0) w.u32LE(requireU16('NpcSell.itemMode', p.itemMode))
w.u32LE(cost >>> 0) w.u32LE(requireU32('NpcSell.cost', p.cost))
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
@ -54,19 +98,41 @@ export function encodeC2sNpcIdentifyAll(npcId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }
/** 0x35 Repair (17B): u8 0x35 | u32 npcId | u32 itemId | u32 tab | u32 cost */ /** 0x35 field4 sign bit: repair every equipped/inventory item (D2Game 0x6fcdde60, price D2Common #10071). */
export function encodeC2sNpcRepair( export const NPC_REPAIR_ALL_FLAG = 0x80000000
npcId: number,
itemId = 0, export interface NpcRepairItemParams {
tab = 0, readonly npcId: number
cost = 0x80000000, readonly itemId: number
): Uint8Array { /** 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) const w = new ByteWriter(17)
w.u8(0x35) w.u8(0x35)
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcRepairItem.npcId', p.npcId))
w.u32LE(itemId >>> 0) w.u32LE(requireU32('NpcRepairItem.itemId', p.itemId))
w.u32LE(tab >>> 0) w.u32LE(requireU16('NpcRepairItem.itemMode', p.itemMode))
w.u32LE(cost >>> 0) 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()) return validateC2sPacket(w.toUint8Array())
} }
@ -79,13 +145,26 @@ export function encodeC2sHireMerc(npcId: number, mercId: number): Uint8Array {
return validateC2sPacket(w.toUint8Array()) 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) const w = new ByteWriter(13)
w.u8(0x38) w.u8(0x38)
w.u32LE(actionType >>> 0) w.u32LE(requireU32('NpcEntityAction.action', action))
w.u32LE(npcId >>> 0) w.u32LE(requireU32('NpcEntityAction.npcId', npcId))
w.u32LE(complement >>> 0) w.u32LE(requireU32('NpcEntityAction.complement', complement))
return validateC2sPacket(w.toUint8Array()) return validateC2sPacket(w.toUint8Array())
} }

View File

@ -4,6 +4,7 @@
import { encodeC2sClickButton } from './interact.ts' 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) return encodeC2sClickButton(action, gold)
} }

View File

@ -40,7 +40,9 @@ import {
encodeC2sNpcEntityAction, encodeC2sNpcEntityAction,
encodeC2sNpcIdentifyAll, encodeC2sNpcIdentifyAll,
encodeC2sNpcInit, encodeC2sNpcInit,
encodeC2sNpcRepair, encodeC2sNpcRepairAll,
encodeC2sNpcRepairItem,
NpcActionId,
encodeC2sNpcResurrectMerc, encodeC2sNpcResurrectMerc,
encodeC2sNpcSell, encodeC2sNpcSell,
} from './c2s/npc.ts' } from './c2s/npc.ts'
@ -468,16 +470,20 @@ export function encodeD2gsClientCommand(
return [encodeC2sNpcInit(cmd.target)] return [encodeC2sNpcInit(cmd.target)]
case 'NpcCancel': case 'NpcCancel':
return [encodeC2sNpcCancel(cmd.target)] return [encodeC2sNpcCancel(cmd.target)]
case 'NpcOpenTrade':
return [encodeC2sNpcEntityAction(NpcActionId.Trade, cmd.npcId, 0)]
case 'NpcOpenGamble':
return [encodeC2sNpcEntityAction(NpcActionId.Gamble, cmd.npcId, 0)]
case 'NpcBuy': case 'NpcBuy':
return [encodeC2sNpcBuy(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] return [encodeC2sNpcBuy(cmd)]
case 'NpcSell': case 'NpcSell':
return [encodeC2sNpcSell(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] return [encodeC2sNpcSell(cmd)]
case 'NpcRepair': case 'NpcRepairItem':
return [encodeC2sNpcRepair(cmd.npcId, cmd.itemId, cmd.tab, cmd.cost)] return [encodeC2sNpcRepairItem(cmd)]
case 'NpcRepairAll':
return [encodeC2sNpcRepairAll(cmd.npcId)]
case 'NpcHeal': case 'NpcHeal':
return [encodeC2sNpcInit({ unitType: 1, id: cmd.npcId })] return [encodeC2sNpcInit({ unitType: 1, id: cmd.npcId })]
case 'NpcGamble':
return [encodeC2sNpcEntityAction(2, cmd.npcId, cmd.itemId ?? 0)]
case 'NpcIdentifyAll': case 'NpcIdentifyAll':
return [encodeC2sNpcIdentifyAll(cmd.npcId)] return [encodeC2sNpcIdentifyAll(cmd.npcId)]
case 'NpcResurrectMerc': case 'NpcResurrectMerc':
@ -493,7 +499,7 @@ export function encodeD2gsClientCommand(
case 'TownPortal': case 'TownPortal':
return [encodeC2sSkillAt('right', { x: 0, y: 0 })] return [encodeC2sSkillAt('right', { x: 0, y: 0 })]
case 'ClickButton': case 'ClickButton':
return [encodeC2sClickButton(cmd.buttonId, cmd.complement)] return [encodeC2sClickButton(cmd.buttonId, cmd.amount)]
case 'Party': case 'Party':
return [encodeC2sParty(cmd.action, cmd.targetPlayerId)] return [encodeC2sParty(cmd.action, cmd.targetPlayerId)]
case 'Hostile': case 'Hostile':

View File

@ -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 { 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) const r = new ByteReader(pkt)
r.u8() // 0x2A r.u8() // 0x2A
const tradeType = r.u8() const flag = r.u8()
const result = r.u8() const result = r.u8()
const npcId = r.u32LE() r.skip(4) // [3..6] uninitialised on the server side
const merchandiseId = r.u32LE() const itemId = r.u32LE()
const goldRemaining = r.u32LE() const gold = r.u32LE()
return { return {
type: 'Npc', type: 'NpcTransaction',
kind: 'transaction', result,
npcId, flag,
result: (result << 8) | tradeType, itemId,
merchandiseId, gold,
goldRemaining,
} }
} }

View File

@ -50,29 +50,39 @@ export type ClientCommand =
| { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number } | { readonly type: 'IdentifyItem'; readonly itemId: number; readonly scrollId: number }
| { readonly type: 'NpcInit'; readonly target: UnitRef } | { readonly type: 'NpcInit'; readonly target: UnitRef }
| { readonly type: 'NpcCancel'; 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 type: 'NpcBuy'
readonly npcId: number readonly npcId: number
readonly itemId: number readonly itemId: number
readonly tab: number readonly itemMode: number
readonly gamble: boolean
readonly shiftFill: boolean
readonly cost: number readonly cost: number
} }
| { | {
/** 0x33 SellItem; field3 = itemMode. */
readonly type: 'NpcSell' readonly type: 'NpcSell'
readonly npcId: number readonly npcId: number
readonly itemId: number readonly itemId: number
readonly tab: number readonly itemMode: number
readonly cost: 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 npcId: number
readonly itemId?: number | undefined readonly itemId: number
readonly tab?: number | undefined readonly itemMode: number
readonly cost?: number | undefined 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: 'NpcHeal'; readonly npcId: number }
| { readonly type: 'NpcGamble'; readonly npcId: number; readonly itemId?: number | undefined }
| { readonly type: 'NpcIdentifyAll'; readonly npcId: number } | { readonly type: 'NpcIdentifyAll'; readonly npcId: number }
| { readonly type: 'NpcResurrectMerc'; readonly npcId: number } | { readonly type: 'NpcResurrectMerc'; readonly npcId: number }
| { readonly type: 'HireMerc'; readonly npcId: number; readonly mercId: 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: 'AddSkillPoint'; readonly skillId: number }
| { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number } | { readonly type: 'WaypointGo'; readonly waypointId: number; readonly destAreaId: number }
| { readonly type: 'TownPortal' } | { 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: 'Party'; readonly action: number; readonly targetPlayerId: number }
| { readonly type: 'Hostile'; readonly targetPlayerId: number; readonly hostile: boolean } | { 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: 'Chat'; readonly text: string; readonly whisperTo?: string | undefined }
| { readonly type: 'OverheadChat'; readonly text: string } | { readonly type: 'OverheadChat'; readonly text: string }
| { readonly type: 'Resurrect' } | { readonly type: 'Resurrect' }

View File

@ -141,13 +141,20 @@ export type ServerEvent =
} }
| { | {
readonly type: 'Npc' readonly type: 'Npc'
readonly kind: 'info' | 'menu' | 'transaction' | 'heal' readonly kind: 'info' | 'menu' | 'heal'
readonly npcId: number readonly npcId: number
readonly result?: number | undefined readonly result?: number | undefined
readonly merchandiseId?: number | undefined
readonly goldRemaining?: number | undefined
readonly raw?: Uint8Array | 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 type: 'WaypointMenu'
readonly unitId: number readonly unitId: number

View File

@ -113,6 +113,8 @@ export { decodeBncsS2cPacket } from './bncs/packets.ts'
export { McpFramer } from './mcp/framing.ts' export { McpFramer } from './mcp/framing.ts'
export { decodeMcpS2cPacket } from './mcp/packets.ts' export { decodeMcpS2cPacket } from './mcp/packets.ts'
export { validateC2sPacket } from './d2gs/framing-c2s.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 { C2S_PACKET_SIZES } from './d2gs/tables/s2c-sizes.ts'
export { export {
D2GS_S2C_REGISTRY, D2GS_S2C_REGISTRY,

View File

@ -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: 'Interact', target: { unitType: 'object', id: asUnitId(100) } }, expectedOpcodes: [0x13] },
{ cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] }, { cmd: { type: 'NpcInit', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x2f] },
{ cmd: { type: 'NpcCancel', target: { unitType: 'monster', id: asUnitId(200) } }, expectedOpcodes: [0x30] }, { 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: 'NpcBuy', npcId: asUnitId(200), itemId: asUnitId(300), itemMode: 0, gamble: false, shiftFill: false, cost: 500 },
{ cmd: { type: 'NpcRepair', npcId: asUnitId(200), itemId: asUnitId(302), tab: 0, cost: 100 }, expectedOpcodes: [0x35] }, 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: '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: false }, expectedOpcodes: [0x16] },
{ cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] }, { cmd: { type: 'ItemPickup', itemId: asUnitId(500), toCursor: true }, expectedOpcodes: [0x16] },

View File

@ -144,13 +144,16 @@ describe('D2GS 1.13c extracted tables, Huffman codec, S2C/C2S framing & registry
{ type: 'IdentifyItem', itemId: 107, scrollId: 108 }, { type: 'IdentifyItem', itemId: 107, scrollId: 108 },
{ type: 'NpcInit', target: { unitType: 1, id: 15 } }, { type: 'NpcInit', target: { unitType: 1, id: 15 } },
{ type: 'NpcCancel', target: { unitType: 1, id: 15 } }, { type: 'NpcCancel', target: { unitType: 1, id: 15 } },
{ type: 'NpcBuy', npcId: 15, itemId: 200, tab: 0, cost: 50 }, { type: 'NpcOpenTrade', npcId: 15 },
{ type: 'NpcSell', npcId: 15, itemId: 200, tab: 0, cost: 25 }, { type: 'NpcOpenGamble', npcId: 15 },
{ type: 'NpcRepair', npcId: 15, itemId: 0, tab: 0, cost: 0 }, { 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: 'AddStatPoint', statId: 0, count: 1 },
{ type: 'AddSkillPoint', skillId: 36 }, { type: 'AddSkillPoint', skillId: 36 },
{ type: 'WaypointGo', waypointId: 30, destAreaId: 1 }, { 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: 'Party', action: 6, targetPlayerId: 2 },
{ type: 'Hostile', targetPlayerId: 2, hostile: true }, { type: 'Hostile', targetPlayerId: 2, hostile: true },
{ type: 'Trade', action: 2, gold: 0 }, { type: 'Trade', action: 2, gold: 0 },

View File

@ -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<typeof encodeD2gsClientCommand>[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,
})
})
})