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:
parent
3c1ed16bf9
commit
8f8ea3b301
|
|
@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue