diablo2-web/tests/e2e-netproto/tier3-cross-feature.test.ts

1036 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 3 — Cross-Feature Pairwise Integration Test Suite (`>= 20` tests, 24 total).
*
* Verifies deterministic pairwise interactions across F1–F20:
* X1 (F1 × F2) : ByteWriter/ByteReader + GBK/UTF-8 text + InMemoryPacketTap
* X2 (F1 × F3) : BitReader/BitWriter + CD-Key hash + CheckRevision + XSHA-1
* X3 (F2 × F4) : MemoryStream + BncsSession + InMemoryPacketTap handshake & login
* X4 (F4 × F5) : BNCS SID_LOGONREALMEX 86B cookie -> MCP_STARTUP 77B & MCP_CHARLIST2
* X5 (F5 × F9) : MCP_JOINGAME gameHash/gameToken -> D2GS 0x68 GameLogon & validation
* X6 (F6 × F7) : Huffman compress + D2gsS2cFramer + decodeD2gsS2cPacket coalesced frame
* X7 (F6 × F8) : Huffman + D2gsS2cFramer + 0x9C ItemAction + 0x26 Chat + 0x28 QuestInfo
* X8 (F7 × F11) : S2C map/entity/stat decoders -> ClientWorld state mirror
* X9 (F8 × F11) : S2C item/skill/chat/quest/waypoint decoders -> ClientWorld state mirror
* X10 (F9 × F10) : D2gsAdapter.send -> encodeD2gsClientCommand -> D2gsSession C2S stream
* X11 (F10 × F11): D2gsSession + D2gsAdapter live S2C stream -> ClientWorld synchronization
* X12 (F11 × F12): ClientWorld LoadAct/RoomReveal -> MapService DRLG room & collision reveal
* X13 (F12 × F13): MapService collision grid -> findPathLocal A* -> LocalMovementPredictor
* X14 (F7 × F13) : S2C 0x15 ReassignPlayer & 0x0F PlayerMove -> Predictor & Interpolator
* X15 (F5 × F14) : d2s-unlock progression & waypoint unlock + MCP bot character validation
* X16 (F15 × F16): SettingsStore persistence <-> Toolbar state & i18n synchronization
* X17 (F2 × F15) : SettingsStore serverHost -> deriveRealmWsEndpoints -> WssBnet guard
* X18 (F11 × F17): ClientWorld units -> ClientWorldSceneSource -> screen click to ClientCommand
* X19 (F16 × F17): Toolbar viewport & lighting toggles -> SceneSource & LightGrid rendering
* X20 (F12 × F18): AutomapView off/level/act modes + 0x07/0x08 room reveal + 1024x768 HUD dock
* X21 (F15 × F19): SettingsStore mute <-> SoundService BGM/spatial SFX + OPFS HTTP Range cache
* X22 (F9 × F20) : OutboundRateLimiter coalescing & anti-flood -> encodeD2gsClientCommand
* X23 (F2 × F20) : InMemoryPacketTap -> PacketInspector filter, JSONL export & replay
* X24 (F16 × F20): Toolbar inspector toggle + diagnostic toast <-> PacketInspector state
*/
import path from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { BitReader } from '../../src/netproto/core/bit-reader.ts'
import { BitWriter } from '../../src/netproto/core/bit-writer.ts'
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import { decodeText, encodeText } from '../../src/netproto/core/text-codec.ts'
import { InMemoryPacketTap } from '../../src/netproto/core/packet-tap.ts'
import { asSubTile, asUnitId } from '../../src/netproto/domain/ids.ts'
import { createMemoryStreamPair } from '../../src/netproto/transport/memory-stream.ts'
import {
createWsBridgeResolver,
resetActiveWssBnetConnectionsForTesting,
resolveEndpointUrl,
} from '../../src/netproto/transport/endpoint.ts'
import { xsha1Words } from '../../src/netproto/crypto/xsha1.ts'
import { doubleHash, hashBnetPassword } from '../../src/netproto/crypto/bnet-hash.ts'
import { createSyntheticCdKeyPair, hashD2CdKey } from '../../src/netproto/crypto/cdkey.ts'
import { D2XP_113C_VERSION_CHECK, getCheckRevision113c } from '../../src/netproto/crypto/check-revision.ts'
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
import {
BncsOpcode,
decodeBncsS2cPacket,
encodeBncsPing,
} from '../../src/netproto/bncs/packets.ts'
import { BncsSession } from '../../src/netproto/bncs/session.ts'
import { encodeMcpFrame, McpFramer } from '../../src/netproto/mcp/framing.ts'
import {
encodeMcpStartup,
McpOpcode,
} from '../../src/netproto/mcp/packets.ts'
import { McpSession } from '../../src/netproto/mcp/session.ts'
import {
defaultD2gsHuffmanCodec,
wrapD2gsCompressedBlock,
} from '../../src/netproto/d2gs/compression.ts'
import { D2gsS2cFramer } from '../../src/netproto/d2gs/framing-s2c.ts'
import { validateC2sPacket } from '../../src/netproto/d2gs/framing-c2s.ts'
import {
decodeD2gsS2cPacket,
encodeD2gsClientCommand,
} from '../../src/netproto/d2gs/registry.ts'
import { ITEM_FLAG_COMPACT } from '../../src/netproto/d2gs/items/item-types.ts'
import { encodeC2sGameLogon } from '../../src/netproto/d2gs/c2s/session.ts'
import { D2gsSession } from '../../src/netproto/d2gs/session.ts'
import { D2gsAdapter } from '../../src/netproto/d2gs/adapter.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { getCanonicalItemDataTables } from '../../src/client/world/item-tables-provider.ts'
import { MapService } from '../../src/client/map/map-service.ts'
import type { TileAtlas } from '../../src/client/map/tile-atlas.ts'
import { findPathLocal } from '../../src/client/motion/pathfind.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
import {
D2S_MAGIC,
D2S_QUEST_OFFSET,
D2S_VERSION_113C,
D2S_WAYPOINT_OFFSET,
unlockD2sBuffer,
validateBotCharacterName,
verifyD2sUnlocked,
writeD2sChecksum,
} from '../../scripts/d2s-unlock.ts'
import {
deriveRealmWsEndpoints,
validateServerHost,
} from '../../src/client/settings/client-settings.ts'
import {
SettingsStore,
type StorageLike,
} from '../../src/client/settings/settings-store.ts'
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
import { tToolbar } from '../../src/i18n/lang.ts'
import {
getViewportProfile,
subTileToScreen,
VIEWPORT_1024x768,
} from '../../src/client/view/viewport-profile.ts'
import {
ClientWorldSceneSource,
type ClientSceneWorldAdapter,
} from '../../src/client/view/scene-source.ts'
import { type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
import { applyLightingInputs } from '../../src/scene/frontend/lighting-presets.ts'
import { translateScreenClickToClientCommand } from '../../src/scene/frontend/mouse-controller.ts'
import { renderSceneSourceFrame } from '../../src/scene/frontend/world-renderer.ts'
import { computeDockingLayout } from '../../src/ui/hud-manager.ts'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import {
computeSpatialAudioMetrics,
SoundService,
type AudioBackendLike,
} from '../../src/client/audio/sound-service.ts'
import {
HttpRangeMpqSource,
InMemoryChunkCache,
OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { LightGrid } from '../../src/game/engine/light-grid.ts'
class MemoryStorage implements StorageLike {
readonly data = new Map<string, string>()
getItem(key: string): string | null {
return this.data.get(key) ?? null
}
setItem(key: string, value: string): void {
this.data.set(key, value)
}
removeItem(key: string): void {
this.data.delete(key)
}
}
const itemTables = getCanonicalItemDataTables()
let sharedMapService: MapService
let sharedTileAtlas: TileAtlas
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir(path.resolve('public/act-packs/tiles'))
sharedTileAtlas = sharedMapService.getTileAtlas()!
}, 60_000)
function buildSynthetic0x9CWorldItemPacket(itemId = 42, worldX = 5100, worldY = 5200): Uint8Array {
const bw = new BitWriter()
bw.writeBits(ITEM_FLAG_COMPACT, 32)
bw.writeBits(101, 10) // version
bw.writeBits(3, 3) // location = 3 (ground)
bw.writeBits(worldX, 16)
bw.writeBits(worldY, 16)
bw.writeAscii('hp1 ')
const bitData = bw.toUint8Array()
const w = new ByteWriter(8 + bitData.length)
w.u8(0x9c)
w.u8(0x00)
w.u8(8 + bitData.length)
w.u8(0x00)
w.u32LE(itemId)
w.bytes(bitData)
return w.toUint8Array()
}
function createSyntheticD2sBuffer(charName = 'd2webbot_sorc'): Uint8Array {
const buf = new Uint8Array(765)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
view.setUint32(0x00, D2S_MAGIC, true)
view.setUint32(0x04, D2S_VERSION_113C, true)
view.setUint32(0x08, buf.byteLength, true)
for (let i = 0; i < Math.min(15, charName.length); i++) {
buf[0x14 + i] = charName.charCodeAt(i) & 0x7f
}
buf[0x24] = 0x20
buf[0x25] = 0x00
buf[0x2b] = 1
buf[D2S_QUEST_OFFSET] = 0x57
buf[D2S_QUEST_OFFSET + 1] = 0x6f
buf[D2S_QUEST_OFFSET + 2] = 0x6f
buf[D2S_QUEST_OFFSET + 3] = 0x21
view.setUint32(D2S_QUEST_OFFSET + 4, 6, true)
view.setUint16(D2S_QUEST_OFFSET + 8, 298, true)
buf[D2S_WAYPOINT_OFFSET] = 0x57
buf[D2S_WAYPOINT_OFFSET + 1] = 0x53
view.setUint32(D2S_WAYPOINT_OFFSET + 2, 1, true)
view.setUint16(D2S_WAYPOINT_OFFSET + 6, 80, true)
for (let diff = 0; diff < 3; diff++) {
const wpBase = 0x281 + diff * 24
buf[wpBase] = 0x02
buf[wpBase + 1] = 0x01
buf[wpBase + 2] = 0x01
}
writeD2sChecksum(buf)
return buf
}
describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
it('X1 (F1 × F2): ByteWriter/ByteReader + GBK/UTF-8 text + InMemoryPacketTap round-trip bilingual packet frames', () => {
const tap = new InMemoryPacketTap()
const writer = new ByteWriter()
writer.u8(0x26)
writer.u32LE(1001)
const gbkSample = new Uint8Array([0xb0, 0xb5, 0xba, 0xda]) // "暗黑" in GBK
writer.bytes(gbkSample)
writer.u8(0)
writer.cstring('Stay awhile and listen!')
const raw = writer.toUint8Array()
tap.record({ t: 12, conn: 'game', dir: 's2c', packetId: 0x26, raw })
expect(tap.entries).toHaveLength(1)
const reader = new ByteReader(tap.entries[0]!.raw)
expect(reader.u8()).toBe(0x26)
expect(reader.u32LE()).toBe(1001)
const gbkBytes = reader.bytes(4)
expect(reader.u8()).toBe(0)
expect(decodeText(gbkBytes, 'gbk')).toBe('暗黑')
expect(reader.cstring()).toBe('Stay awhile and listen!')
expect(decodeText(encodeText('暗黑破坏神II', 'utf-8'), 'utf-8')).toBe('暗黑破坏神II')
})
it('X2 (F1 × F3): BitWriter/BitReader + CD-Key hash + CheckRevision + XSHA-1 serialize and verify auth primitives', () => {
const pair = createSyntheticCdKeyPair(7)
const keyHash = hashD2CdKey(0x11223344, 0x55667788, pair.classic)
const rev = getCheckRevision113c()
const pwBytes = doubleHash(0x11223344, 0x55667788, hashBnetPassword('secret'))
const pwFirstWord = new DataView(pwBytes.buffer, pwBytes.byteOffset, pwBytes.byteLength).getUint32(0, true)
const bw = new BitWriter()
bw.writeBits(rev.exeVersion, 32)
bw.writeBits(rev.exeHash, 32)
bw.writeBits(keyHash.productValue, 32)
bw.writeBits(keyHash.publicValue, 32)
bw.writeBits(pwFirstWord, 32)
const packed = bw.toUint8Array()
const br = new BitReader(packed)
expect(br.readBits(32) >>> 0).toBe(D2XP_113C_VERSION_CHECK.exeVersion >>> 0)
expect(br.readBits(32) >>> 0).toBe(rev.exeHash >>> 0)
expect(br.readBits(32) >>> 0).toBe(keyHash.productValue >>> 0)
expect(br.readBits(32) >>> 0).toBe(keyHash.publicValue >>> 0)
expect(br.readBits(32) >>> 0).toBe(pwFirstWord >>> 0)
expect(xsha1Words(packed)).toHaveLength(5)
})
it('X3 (F2 × F4): MemoryStream + BncsSession + InMemoryPacketTap complete BNCS handshake, SID_PING, and login', async () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const tap = new InMemoryPacketTap()
const bncs = new BncsSession(clientStream, { clientToken: 0x12345678, packetTap: tap })
const serverFramer = new BncsFramer()
let initByteSeen = false
serverStream.onData((chunk) => {
let data = chunk
if (!initByteSeen && data[0] === 0x01) {
initByteSeen = true
data = data.subarray(1)
}
for (const pkt of serverFramer.push(data)) {
if (pkt.id === BncsOpcode.SID_AUTH_INFO) {
serverStream.write(encodeBncsPing(0xaabbccdd))
const w50 = new ByteWriter()
w50.u32LE(0)
w50.u32LE(0x87654321)
w50.u32LE(0)
w50.u32LE(0)
w50.u32LE(0)
w50.cstring('ver-IX86-1.mpq')
w50.cstring('A=1 B=2 C=3 4 A=A^S B=B^C C=C^A A=A^B')
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_INFO, w50.toUint8Array()))
} else if (pkt.id === BncsOpcode.SID_AUTH_CHECK) {
serverStream.write(encodeBncsFrame(BncsOpcode.SID_AUTH_CHECK, new Uint8Array([0, 0, 0, 0, 0])))
} else if (pkt.id === BncsOpcode.SID_LOGONRESPONSE2) {
serverStream.write(encodeBncsFrame(BncsOpcode.SID_LOGONRESPONSE2, new Uint8Array([0, 0, 0, 0])))
}
}
})
await bncs.handshake()
const login = await bncs.login('d2webbot1', 'pass')
expect(login.status).toBe(0)
expect(tap.entries.some((e) => e.conn === 'bnet' && e.packetId === BncsOpcode.SID_PING)).toBe(true)
bncs.close()
})
it('X4 (F4 × F5): BNCS SID_LOGONREALMEX 86-byte realm cookie pipes directly into McpSession.startup and listChars', async () => {
const w3e = new ByteWriter()
w3e.u32LE(0x11223344) // mcpCookie
w3e.u32LE(0) // mcpStatus
w3e.bytes(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])) // mcpChunk1 (8B)
w3e.bytes(new Uint8Array([101, 37, 117, 183])) // IP 101.37.117.183
w3e.u16BE(6113) // port in network byte order at offset 20
w3e.u16LE(0)
w3e.bytes(new Uint8Array(48).fill(0x55)) // mcpChunk2 (48B)
w3e.cstring('d2webbot1')
const [realmPkt] = new BncsFramer().push(encodeBncsFrame(BncsOpcode.SID_LOGONREALMEX, w3e.toUint8Array()))
const decoded = decodeBncsS2cPacket(realmPkt!)
expect(decoded.type).toBe('SID_LOGONREALMEX')
if (decoded.type !== 'SID_LOGONREALMEX') throw new Error('expected SID_LOGONREALMEX')
const startupFrame = encodeMcpStartup(decoded.data)
expect(startupFrame.byteLength).toBe(77)
const [mcpClient, mcpServer] = createMemoryStreamPair()
const mcp = new McpSession(mcpClient)
let initSeen = false
const mcpFramer = new McpFramer()
mcpServer.onData((chunk) => {
let data = chunk
if (!initSeen && data[0] === 0x01) {
initSeen = true
data = data.subarray(1)
}
for (const pkt of mcpFramer.push(data)) {
if (pkt.id === McpOpcode.MCP_STARTUP) {
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === McpOpcode.MCP_CHARLIST2) {
const w = new ByteWriter()
w.u16LE(8)
w.u32LE(1)
w.u16LE(1)
w.u32LE(0x7fffffff)
w.cstring('d2webbot_sorc')
const stat = new Uint8Array(33).fill(0xff)
stat[0] = 0x84
stat[1] = 0x80
stat[13] = 0x02 // Sorceress
stat[25] = 80 // Level 80
stat[26] = 0xa0 // Expansion
stat[27] = 0x80
w.bytes(stat)
w.u8(0)
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_CHARLIST2, w.toUint8Array()))
}
}
})
const status = await mcp.startup(decoded.data)
expect(status).toBe(0)
const chars = await mcp.listChars(8)
expect(chars).toHaveLength(1)
expect(chars[0]!.name).toBe('d2webbot_sorc')
expect(chars[0]!.level).toBe(80)
mcp.close()
})
it('X5 (F5 × F9): McpSession.joinGame credentials feed encodeC2sGameLogon (0x68, 37B) and validateC2sPacket', async () => {
const [mcpClient, mcpServer] = createMemoryStreamPair()
const mcp = new McpSession(mcpClient)
let initSeen = false
const mcpFramer = new McpFramer()
mcpServer.onData((chunk) => {
let data = chunk
if (!initSeen && data[0] === 0x01) {
initSeen = true
data = data.subarray(1)
}
for (const pkt of mcpFramer.push(data)) {
if (pkt.id === McpOpcode.MCP_STARTUP) {
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === McpOpcode.MCP_CHARLOGON) {
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
} else if (pkt.id === McpOpcode.MCP_JOINGAME) {
const reqId = new ByteReader(pkt.payload).u16LE()
const w = new ByteWriter()
w.u16LE(reqId)
w.u16LE(0x0042) // gameToken
w.u16LE(0) // unknown
w.bytes(new Uint8Array([101, 37, 117, 183])) // d2gsIp
w.u32LE(0xdeadbeef) // gameHash
w.u32LE(0) // status = 0 (OK)
mcpServer.write(encodeMcpFrame(McpOpcode.MCP_JOINGAME, w.toUint8Array()))
}
}
})
await mcp.startup({
mcpCookie: 1,
mcpStatus: 0,
mcpChunk1: new Uint8Array(8),
mcpIp: '101.37.117.183',
mcpPort: 6113,
mcpChunk2: new Uint8Array(48),
uniqueName: 'bot',
})
await mcp.charLogon('d2webbot_sorc')
const joinRes = await mcp.joinGame('testgame', 'pw')
expect(joinRes.status).toBe(0)
expect(joinRes.gameToken).toBe(0x0042)
expect(joinRes.gameHash).toBe(0xdeadbeef)
const logonPkt = encodeC2sGameLogon({
gameHash: joinRes.gameHash,
gameToken: joinRes.gameToken,
charClass: 1,
charName: 'd2webbot_sorc',
})
expect(logonPkt.byteLength).toBe(37)
expect(() => validateC2sPacket(logonPkt)).not.toThrow()
mcp.close()
})
it('X6 (F6 × F7): Huffman compression + D2gsS2cFramer + decodeD2gsS2cPacket decode coalesced S2C world block', () => {
const framer = new D2gsS2cFramer()
framer.push(new Uint8Array([0xaf, 0x01]))
// Build raw block containing: 0x03 (LoadAct, 12B) + 0x07 (AddRoomData, 6B) + 0x15 (ReassignPlayer, 11B)
const w = new ByteWriter()
w.u8(0x03)
w.u8(0)
w.u32LE(0x12345678)
w.u16LE(1)
w.u32LE(0)
w.u8(0x07)
w.u16LE(1020)
w.u16LE(1000)
w.u8(1)
w.u8(0x15)
w.u8(0)
w.u32LE(1)
w.u16LE(5100)
w.u16LE(5050)
w.u8(1)
const block = wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(w.toUint8Array()))
const framed = framer.push(block)
expect(framed.map((p) => p.packetId)).toEqual([0x03, 0x07, 0x15])
const events = framed.map((p) => decodeD2gsS2cPacket(p.bytes, { tables: itemTables }))
expect(events.map((e) => e.type)).toEqual(['LoadAct', 'RoomReveal', 'UnitReassign'])
})
it('X7 (F6 × F8): Huffman + D2gsS2cFramer + 0x9C ItemAction + 0x26 Chat + 0x28 QuestInfo decode from single compressed block', () => {
const framer = new D2gsS2cFramer()
framer.push(new Uint8Array([0xaf, 0x01]))
const itemPkt = buildSynthetic0x9CWorldItemPacket(88, 5112, 5044)
const chatW = new ByteWriter()
chatW.u8(0x26)
chatW.u8(0x01)
chatW.u8(0x00)
chatW.u8(0x00)
chatW.u32LE(1)
chatW.u8(0x00)
chatW.u8(0x00)
chatW.cstring('Akara')
chatW.cstring('There is a place of great evil in the wilderness.')
const chatPkt = chatW.toUint8Array()
const questPkt = new Uint8Array(103)
questPkt[0] = 0x28
questPkt[1] = 0
questPkt[2] = 1
const combined = new Uint8Array(itemPkt.length + chatPkt.length + questPkt.length)
combined.set(itemPkt, 0)
combined.set(chatPkt, itemPkt.length)
combined.set(questPkt, itemPkt.length + chatPkt.length)
const block = wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(combined))
const pkts = framer.push(block)
expect(pkts.map((p) => p.packetId)).toEqual([0x9c, 0x26, 0x28])
const decoded = pkts.map((p) => decodeD2gsS2cPacket(p.bytes, { tables: itemTables }))
expect(decoded[0]!.type).toBe('ItemEvent')
expect(decoded[1]!.type).toBe('Chat')
expect(decoded[2]!.type).toBe('QuestState')
})
it('X8 (F7 × F11): S2C map, entity, movement, and attribute decoders populate ClientWorld state', () => {
const world = new ClientWorld()
// 0x03 LoadAct
const w03 = new ByteWriter()
w03.u8(0x03)
w03.u8(0)
w03.u32LE(0xdeadbeef)
w03.u16LE(1)
w03.u32LE(0)
world.apply(decodeD2gsS2cPacket(w03.toUint8Array(), { tables: itemTables }))
// 0x59 AssignPlayer (self spawn)
const w59 = new ByteWriter()
w59.u8(0x59)
w59.u32LE(1)
w59.u8(1) // Sorceress
const nameBytes = new Uint8Array(16)
nameBytes.set(encodeText('d2webbot_sorc', 'utf-8'))
w59.bytes(nameBytes)
w59.u16LE(5100)
w59.u16LE(5050)
world.apply(decodeD2gsS2cPacket(w59.toUint8Array(), { tables: itemTables }))
// 0xAC AssignNpc
const wac = new ByteWriter()
wac.u8(0xac)
wac.u32LE(300)
wac.u16LE(148) // Akara
wac.u16LE(5120)
wac.u16LE(5040)
wac.u8(128)
wac.u8(13)
world.apply(decodeD2gsS2cPacket(wac.toUint8Array(), { tables: itemTables }))
// 0x1D AttributeByte (Stat 0 Strength = 85)
world.apply(decodeD2gsS2cPacket(new Uint8Array([0x1d, 0, 85]), { tables: itemTables }))
expect(world.mapSeed).toBe(0xdeadbeef)
expect(world.areaId).toBe(1)
expect(world.selfId).toBe(1)
expect(world.self.x).toBe(5100)
expect(world.getUnit('monster', 300)?.classId).toBe(148)
expect(world.self.stats.get(0)).toBe(85)
})
it('X9 (F8 × F11): S2C item, skill, chat, quest, and waypoint decoders update ClientWorld state', () => {
const world = new ClientWorld()
world.apply(decodeD2gsS2cPacket(buildSynthetic0x9CWorldItemPacket(99, 5105, 5055), { tables: itemTables }))
expect(world.getUnit('item', 99)?.x).toBe(5105)
// 0x23 AssignSkill
const w23 = new ByteWriter()
w23.u8(0x23)
w23.u8(0x00)
w23.u32LE(0)
w23.u8(0) // right hand
w23.u16LE(54) // Teleport
w23.u32LE(0xffffffff)
world.apply(decodeD2gsS2cPacket(w23.toUint8Array(), { tables: itemTables }))
expect(world.self.rightSkill).toBe(54)
// 0x63 OpenWaypoint (21B)
const w63 = new ByteWriter()
w63.u8(0x63)
w63.u32LE(500) // waypoint unitId
w63.bytes(new Uint8Array(16).fill(0xff))
world.apply(decodeD2gsS2cPacket(w63.toUint8Array(), { tables: itemTables }))
expect(world.waypoints.size).toBeGreaterThan(0)
})
it('X10 (F9 × F10): D2gsAdapter.send encodes ClientCommand variants and writes valid C2S frames over D2gsSession', async () => {
const [client, server] = createMemoryStreamPair()
const c2sReceived: Uint8Array[] = []
server.onData((chunk) => {
c2sReceived.push(chunk)
if (chunk[0] === 0x68) {
server.write(
wrapD2gsCompressedBlock(
defaultD2gsHuffmanCodec.compress(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1, 0x02, 0x04])),
),
)
}
})
const session = new D2gsSession({
stream: client,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'd2webbot_sorc' },
})
const adapter = new D2gsAdapter({ session, tables: itemTables })
server.write(new Uint8Array([0xaf, 0x01]))
adapter.send({ type: 'Move', run: true, to: { x: 5110, y: 5060 } })
adapter.send({ type: 'SelectSkill', skill: 54, hand: 'right' })
adapter.send({ type: 'SkillAt', hand: 'right', at: { x: 5120, y: 5070 } })
adapter.send({ type: 'Interact', target: { unitType: 2, id: 500 } })
adapter.send({ type: 'WaypointGo', waypointId: 500, destAreaId: 3 })
for (const pkt of c2sReceived) {
expect(() => validateC2sPacket(pkt)).not.toThrow()
}
const opcodes = c2sReceived.map((p) => p[0])
expect(opcodes).toEqual(expect.arrayContaining([0x68, 0x6b, 0x03, 0x3c, 0x0c, 0x13, 0x49]))
await adapter.close()
})
it('X11 (F10 × F11): D2gsSession + D2gsAdapter stream S2C events directly into ClientWorld', async () => {
const [client, server] = createMemoryStreamPair()
server.onData((chunk) => {
if (chunk[0] === 0x68) {
const w = new ByteWriter()
w.bytes(new Uint8Array([0x01, 0, 0, 0, 0, 0, 1, 1]))
w.u8(0x02)
w.u8(0x03)
w.u8(0)
w.u32LE(0x55aa55aa)
w.u16LE(1)
w.u32LE(0)
w.u8(0x04)
w.u8(0x15)
w.u8(0)
w.u32LE(1)
w.u16LE(5112)
w.u16LE(5068)
w.u8(1)
server.write(wrapD2gsCompressedBlock(defaultD2gsHuffmanCodec.compress(w.toUint8Array())))
}
})
const session = new D2gsSession({
stream: client,
logon: { gameHash: 1, gameToken: 1, charClass: 1, charName: 'd2webbot_sorc' },
})
const adapter = new D2gsAdapter({ session, tables: itemTables })
const world = new ClientWorld()
adapter.onEvent((ev) => world.apply(ev))
server.write(new Uint8Array([0xaf, 0x01]))
expect(world.mapSeed).toBe(0x55aa55aa)
expect(world.areaId).toBe(1)
expect(world.self.x).toBe(5112)
expect(world.self.y).toBe(5068)
await adapter.close()
})
it('X12 (F11 × F12): ClientWorld LoadAct and RoomReveal drive MapService DRLG room reveal and collision alignment', () => {
const world = new ClientWorld()
world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x00abcdef, areaId: 1 })
const fullView = sharedMapService.onLoadActPacket({
act: 0,
mapSeed: world.mapSeed,
areaId: world.areaId,
})!
expect(fullView.rooms.length).toBeGreaterThan(0)
const room0 = fullView.rooms[0]!
world.apply({ type: 'RoomReveal', areaId: 1, tileX: room0.tileX, tileY: room0.tileY })
const addRes = sharedMapService.onAddRoomDataPacket(
{ areaId: 1, roomX: room0.tileX, roomY: room0.tileY },
{ rebuildView: true, revealMode: 'revealed-only' },
)
expect(addRes.room).not.toBeNull()
expect(sharedMapService.getActiveLevelView()?.rooms.length).toBeGreaterThanOrEqual(1)
expect(sharedTileAtlas.verifyUniversalDt1s().allValid).toBe(true)
})
it('X13 (F12 × F13): MapService collision grid feeds findPathLocal A* and LocalMovementPredictor', () => {
const view = sharedMapService.loadAct({ act: 0, seed: 0x00abcdef, areaId: 1 })!
const walkable = new Uint8Array(view.collision.length)
for (let i = 0; i < view.collision.length; i++) {
walkable[i] = (view.collision[i]! & 0x01) === 0 ? 1 : 0
}
let startX = Math.floor(view.subWidth / 2)
let startY = Math.floor(view.subHeight / 2)
outer: for (let y = 10; y < view.subHeight - 20; y++) {
for (let x = 10; x < view.subWidth - 20; x++) {
if (walkable[y * view.subWidth + x] === 1 && walkable[y * view.subWidth + (x + 8)] === 1) {
startX = x
startY = y
break outer
}
}
}
const pathRes = findPathLocal(
{ subWidth: view.subWidth, subHeight: view.subHeight, walkable },
startX,
startY,
startX + 8,
startY,
{ smooth: true },
)
expect(pathRes.reachedGoal).toBe(true)
const predictor = new LocalMovementPredictor({ initialX: startX, initialY: startY })
predictor.setPath(pathRes.path, true)
const stepped = predictor.stepTicks(15)
expect(stepped.x).toBeGreaterThan(startX)
})
it('X14 (F7 × F13): S2C 0x15 ReassignPlayer and 0x0F PlayerMove drive LocalMovementPredictor and RemoteEntityInterpolator', () => {
const predictor = new LocalMovementPredictor({ initialX: 5100, initialY: 5050 })
const interp = new RemoteEntityInterpolator()
const w15Small = new ByteWriter()
w15Small.u8(0x15)
w15Small.u8(0)
w15Small.u32LE(1)
w15Small.u16LE(5103)
w15Small.u16LE(5050)
w15Small.u8(0)
const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables })
if (evSmall.type === 'UnitReassign') {
const rec = predictor.reconcileServerPosition(evSmall.at, evSmall.snap)
expect(rec.mode).toBe('rubber-band')
}
const w0f = new ByteWriter()
w0f.u8(0x0f)
w0f.u8(0)
w0f.u32LE(2)
w0f.u8(0x17)
w0f.u16LE(5210)
w0f.u16LE(5100)
w0f.u8(0)
w0f.u16LE(5200)
w0f.u16LE(5100)
const evMove = decodeD2gsS2cPacket(w0f.toUint8Array(), { tables: itemTables })
expect(evMove.type).toBe('UnitMove')
if (evMove.type === 'UnitMove') {
interp.spawnOrSnap({ unitType: 0, unitId: evMove.id, x: 5200, y: 5100 })
interp.startMove({
unitType: 0,
unitId: evMove.id,
currentX: 5200,
currentY: 5100,
targetX: evMove.to.x,
targetY: evMove.to.y,
moveType: 0x17,
})
interp.update(200)
expect(interp.getEntityState(0, 2)!.x).toBeGreaterThan(5200)
}
})
it('X15 (F5 × F14): validateBotCharacterName + unlockD2sBuffer + verifyD2sUnlocked prepare bot save for MCP logon', () => {
const botName = 'd2webbot_sorc'
expect(() => validateBotCharacterName(botName)).not.toThrow()
const rawD2s = createSyntheticD2sBuffer(botName)
const unlocked = unlockD2sBuffer(rawD2s)
const verification = verifyD2sUnlocked(unlocked)
expect(verification.characterName).toBe(botName)
expect(verification.checksumValid).toBe(true)
expect(verification.allUnlocked).toBe(true)
})
it('X16 (F15 × F16): SettingsStore and Toolbar synchronize viewport, lighting, automapReveal, and language', () => {
const storage = new MemoryStorage()
const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
const toolbar = new Toolbar({ store })
toolbar.toggleViewport()
toolbar.setLightingPreset('fullbright')
toolbar.setAutomapReveal('act')
toolbar.toggleLanguage()
const snap = toolbar.getSnapshot()
expect(snap.settings.viewport).toBe('1024x768')
expect(snap.settings.lightingPreset).toBe('fullbright')
expect(snap.settings.automapReveal).toBe('act')
expect(snap.settings.lang).toBe('zh')
expect(snap.statusLabel).toBe(tToolbar('toolbar.status.disconnected', 'zh'))
const reloaded = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
expect(reloaded.get().viewport).toBe('1024x768')
expect(reloaded.get().lightingPreset).toBe('fullbright')
expect(reloaded.get().automapReveal).toBe('act')
expect(reloaded.get().lang).toBe('zh')
toolbar.destroy()
})
it('X17 (F2 × F15): SettingsStore serverHost drives deriveRealmWsEndpoints and WssBnet concurrency guard', async () => {
resetActiveWssBnetConnectionsForTesting()
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const res = store.setServerHost('www.laiseek.xyz', 'https:')
expect(res.valid).toBe(true)
const endpoints = deriveRealmWsEndpoints(store.get().serverHost, 'https:')
expect(endpoints.bncsUrl).toBe('wss://www.laiseek.xyz/ws/bncs')
expect(resolveEndpointUrl('bnet', store.get().serverHost, 6112, { scheme: 'wss', basePath: '/d2net' })).toBe(
'wss://www.laiseek.xyz/d2net/bnet',
)
const resolver = createWsBridgeResolver({
host: store.get().serverHost,
maxConcurrentBnetWss: 2,
streamFactory: () => createMemoryStreamPair()[0],
})
const s1 = await resolver.open('bnet', store.get().serverHost, 6112)
const s2 = await resolver.open('bnet', store.get().serverHost, 6112)
await expect(resolver.open('bnet', store.get().serverHost, 6112)).rejects.toThrow(/concurrency limit/i)
s1.close()
s2.close()
})
it('X18 (F11 × F17): ClientWorld units project into ClientWorldSceneSource and screen click translates to ClientCommand', () => {
const world = new ClientWorld()
world.apply({ type: 'LoadAct', act: 0, mapSeed: 0x12345678, areaId: 1 })
world.apply({
type: 'UnitAssign',
unit: { unitType: 'player', id: 1, classId: 1, x: 100, y: 100, name: 'd2webbot_sorc' },
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' },
})
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(world.mapSeed, 0, world.areaId)
const units: RenderableUnitInput[] = [
{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 },
{ kind: 'monster', id: 77, classId: 10, x: 106, y: 100, lifePct: 100, name: 'Fallen' },
]
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: 100,
playerSubY: 100,
units,
levelView,
}),
'1024x768',
)
const monsterScreen = subTileToScreen(106, 100, 100, 100, VIEWPORT_1024x768)
const cmd = translateScreenClickToClientCommand(monsterScreen.x, monsterScreen.y - 16, source, {
button: 'right',
runMode: true,
})
expect(cmd).toEqual({
type: 'CastRightSkillOnTarget',
unitKind: 'monster',
unitId: asUnitId(77),
})
})
it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const toolbar = new Toolbar({ store })
const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x12345678, 0, 1)
const source = new ClientWorldSceneSource(
(): ClientSceneWorldAdapter => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: 100,
playerSubY: 100,
units: [{ kind: 'player', id: 1, classId: 1, x: 100, y: 100 }],
levelView,
}),
store.get().viewport,
)
toolbar.toggleViewport()
source.setViewportProfile(store.get().viewport)
const lightGrid = new LightGrid()
let lastRendererLighting: unknown = 'unset'
const mockRenderer = {
setLighting(params: unknown) {
lastRendererLighting = params
},
}
toolbar.setLightingPreset('torch')
const snapTorch = renderSceneSourceFrame({
source,
lightingPreset: store.get().lightingPreset,
lightingTargets: { lightGrid, renderer: mockRenderer },
})
expect(snapTorch.viewportProfile.width).toBe(1024)
expect(lastRendererLighting).not.toBeNull()
toolbar.setLightingPreset('fullbright')
applyLightingInputs(source.buildLightingInputs(store.get().lightingPreset), {
lightGrid,
renderer: mockRenderer,
})
expect(lastRendererLighting).toBeNull()
toolbar.destroy()
})
it('X20 (F12 × F18): AutomapView off/level/act modes + S2C room reveal + 1024x768 HUD docking layout', () => {
const automap = new AutomapView({
initialMode: 'off',
actRevealLevelIds: { 1: [1, 2], 2: [40], 3: [75], 4: [103], 5: [109] },
})
const view = automap.setContext(0x00abcdef, 0, 1)
expect(automap.buildSnapshot().visibleRoomCount).toBe(0)
const firstRoom = view.rooms[0]!
automap.onServerAddRoom(firstRoom.tileX, firstRoom.tileY, 1)
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThanOrEqual(1)
automap.setMode('level')
expect(automap.buildSnapshot().visibleRoomCount).toBe(view.rooms.length)
automap.setMode('act')
expect(automap.buildSnapshot().visibleAreaIds).toEqual([1, 2])
const dock = computeDockingLayout(1024, 768, 1, getViewportProfile('1024x768'))
expect(dock.uiScale).toBe(1)
expect(dock.channelWidthLogical).toBe(224)
})
it('X21 (F15 × F19): SettingsStore audioMuted synchronizes SoundService BGM/SFX and OpfsMpqManager caches chunks', async () => {
const playedBgm: number[] = []
const playedSfx: string[] = []
const backend: AudioBackendLike = {
async init() {},
setMuted() {},
async playBGM(act) {
playedBgm.push(act)
},
playSFX(id) {
playedSfx.push(id)
},
playButtonClick() {},
playFootstep() {},
playPortalEnter() {},
playWaypointActivate() {},
}
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const svc = new SoundService({ store, backend })
await svc.init()
await svc.onEnterArea(109) // Act 5 Harrogath
expect(playedBgm).toEqual([5])
const spatial = computeSpatialAudioMetrics(104, 100, 100, 100)
expect(spatial.culled).toBe(false)
expect(svc.playSoundById(47, { sourceSubX: 104, sourceSubY: 100, listenerSubX: 100, listenerSubY: 100 })).not.toBeNull()
store.set({ audioMuted: true })
expect(svc.isMuted()).toBe(true)
expect(svc.playSoundById(47)).toBeNull()
const cache = new InMemoryChunkCache()
const mgr = new OpfsMpqManager({
baseUrl: '/diablo2/data',
cache,
fetchFn: async () => ({
ok: true,
status: 206,
headers: { get: () => 'bytes 0-15/64' },
arrayBuffer: async () => new Uint8Array(16).fill(0xaa).buffer,
}),
})
const src = mgr.getSource('d2sfx.mpq') as HttpRangeMpqSource
await src.read(0, 16)
await src.read(0, 16)
expect(src.cacheHitsCount).toBe(1)
svc.destroy()
})
it('X22 (F9 × F20): OutboundRateLimiter coalesces rapid moves and encodes only allowed C2S commands', () => {
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
const sentFrames: Uint8Array[] = []
const trySendMove = (x: number, y: number, nowMs: number) => {
const decision = limiter.tryAcquire(
{ type: 'RunToLocation', x: asSubTile(x), y: asSubTile(y) },
nowMs,
)
if (decision.allowed) {
const frames = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x, y } })
sentFrames.push(...frames)
}
}
trySendMove(5100, 5050, 1000)
trySendMove(5105, 5055, 1030)
trySendMove(5110, 5060, 1060)
expect(sentFrames).toHaveLength(1)
const flushed = limiter.flushPendingMove(1150)
expect(flushed).not.toBeNull()
if (flushed?.type === 'RunToLocation') {
const frames = encodeD2gsClientCommand({ type: 'Move', run: true, to: { x: flushed.x, y: flushed.y } })
sentFrames.push(...frames)
}
expect(sentFrames).toHaveLength(2)
expect(new ByteReader(sentFrames[1]!.subarray(1)).u16LE()).toBe(5110)
})
it('X23 (F2 × F20): InMemoryPacketTap + PacketInspector filter, export .d2cap JSONL, and replay into ClientWorld', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap()
const world = new ClientWorld()
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: (frame, rawBytes) => {
if (frame.phase === 'd2gs' && frame.dir === 's2c') {
world.apply(decodeD2gsS2cPacket(rawBytes, { tables: itemTables }))
}
},
})
const w03 = new ByteWriter()
w03.u8(0x03)
w03.u8(0)
w03.u32LE(0x13572468)
w03.u16LE(1)
w03.u32LE(0)
tap.record({ t: 10, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
tap.record({ t: 20, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 77]) })
const jsonl = inspector.exportD2CapJsonl()
inspector.clear()
inspector.loadD2CapJsonl(jsonl)
inspector.replayAll()
expect(world.mapSeed).toBe(0x13572468)
expect(world.self.stats.get(0)).toBe(77)
inspector.destroy()
})
it('X24 (F16 × F20): Toolbar toggleInspector and reportDiagnostic synchronize with SettingsStore and PacketInspector', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap()
const toolbar = new Toolbar({ store })
const inspector = new PacketInspector({ store, tap })
expect(inspector.isOpen()).toBe(false)
toolbar.setInspectorOpen(true)
expect(inspector.isOpen()).toBe(true)
toolbar.reportDiagnostic('custom', 'Unknown S2C 0xFE')
const snap = toolbar.getSnapshot()
expect(snap.connection.phase).toBe('error')
expect(snap.diagnosticBannerText).toContain('0xFE')
expect(validateServerHost(snap.settings.serverHost, 'https:').valid).toBe(true)
inspector.destroy()
toolbar.destroy()
})
})