From f8c938f86608e9d2c0cd0c9ca2b9177ec08d4785 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 15:00:35 +0000 Subject: [PATCH] Fix S2C 0x59 identity stability and 0x8E corpse packets (Closes #563, #564, #567, #569, #570) --- src/client/session/online-session.ts | 29 + src/client/world/client-unit.ts | 1 + src/client/world/client-world.ts | 102 ++-- src/netproto/d2gs/s2c/movement.ts | 33 +- src/netproto/d2gs/s2c/party.ts | 26 +- src/netproto/d2gs/s2c/skills.ts | 23 +- src/netproto/d2gs/s2c/units.ts | 13 +- src/netproto/domain/server-event.ts | 9 +- tests/client/client-world-identity.test.ts | 533 ++++++++++++++++++ .../tier1-feature-coverage.test.ts | 7 +- tools/d2-bot.ts | 2 +- 11 files changed, 694 insertions(+), 84 deletions(-) create mode 100644 tests/client/client-world-identity.test.ts diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index dea2103..582818e 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1603,6 +1603,35 @@ export class OnlineSession { break } + case 'UnitMode': { + const uType = unitTypeToId(ev.unitType) + const uId = ev.id + const isLocalPlayer = uType === 0 && (uId === this.world.selfId || this.world.selfId === 0) + if (isLocalPlayer) { + if (ev.mode === 0 || ev.mode === 17) { + this.predictor.stop() + } + } else { + const unit = this.world.getUnit(ev.unitType, uId) + const stopX = ev.at?.x ?? unit?.x + const stopY = ev.at?.y ?? unit?.y + if ( + stopX !== undefined && + stopY !== undefined && + (ev.mode === undefined || + (ev.mode !== 2 && ev.mode !== 3 && ev.mode !== 6 && ev.mode !== 15)) + ) { + this.interpolator.stopEntity({ + unitType: uType, + unitId: uId, + x: stopX, + y: stopY, + }) + } + } + break + } + case 'UnitRemove': { this.interpolator.removeEntity(unitTypeToId(ev.unitType), ev.id) break diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index ca43c3e..a063c20 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -106,6 +106,7 @@ export class ClientUnit { return { ...this._turnState } } lifePct: number + hitClass?: number | undefined flags: number warpId?: number | undefined objectState?: number | undefined diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 95362a5..9e7684e 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -317,6 +317,7 @@ export class ClientWorld { private _waypointUnitId: number | null = null private _quests = new Uint16Array(48) private readonly _party = new Map() + private readonly _corpses = new Map() private readonly _trade: TradeSessionState = { active: false, lastAction: 0, @@ -1176,6 +1177,10 @@ export class ClientWorld { return this._party } + get corpses(): ReadonlyMap { + return this._corpses + } + get trade(): Readonly { return this._trade } @@ -1757,7 +1762,7 @@ export class ClientWorld { : 0 ev = { type: 'UnitAssign', - isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)), + isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId !== 0 ? this.selfId === uId : ev.isSelf === true)), unit: { unitType: uType, id: uId, @@ -1947,10 +1952,10 @@ export class ClientWorld { const isSelfPlayer = unit.unitType === 'player' && - (ev.isSelf === true || this.selfId === 0 || this.selfId === unit.id) + (this.selfId !== 0 ? unit.id === this.selfId : ev.isSelf === true) if (isSelfPlayer) { - if (ev.isSelf === true || this.selfId === 0) { + if (this.selfId === 0 && ev.isSelf === true) { this.selfId = unit.id this._self.unitId = unit.id } @@ -2043,7 +2048,7 @@ export class ClientWorld { unit.faceToward(targetX, targetY, false) } } - if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { + if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 17) { const inTown = isTownLevel(this.areaId) if (moveMode === 'stop') { unit.mode = inTown ? 5 : 1 @@ -2112,37 +2117,55 @@ export class ClientWorld { case 'UnitMode': { const uId = Number(ev.id ?? ev.unitId ?? 0) const unit = this.ensureUnit(ev.unitType, uId) - this.clearUnitCastState(unit) - const now = this.resolveEventNowMs(nowMs) - unit.mode = ev.mode - unit.modeStartedAtMs = now - unit.modeElapsedMs = 0 - if (unit.unitType === 'player' && ev.mode === 4) { - const speedInfo = this.resolvePlayerAnimSpeed(4, unit.id) - unit.modeDurationMs = speedInfo.durationMs - unit.modeUntilMs = now + speedInfo.durationMs - unit.animStepSpeed256 = speedInfo.stepSpeed256 - unit.animEffRate = speedInfo.effRate - } else if (unit.unitType === 'monster' && ev.mode === 3) { - unit.modeDurationMs = 240 - unit.modeUntilMs = now + 240 - unit.animStepSpeed256 = undefined - unit.animEffRate = undefined - } else { - unit.modeDurationMs = undefined - unit.modeUntilMs = undefined - unit.animStepSpeed256 = undefined - unit.animEffRate = undefined + if (ev.mode !== undefined) { + if (unit.unitType === 'player' && (ev.mode < 0 || ev.mode > 19)) { + throw new RangeError(`Invalid PlrMode: ${ev.mode}, expected 0..19`) + } + if (unit.unitType === 'monster' && (ev.mode < 0 || ev.mode > 15)) { + throw new RangeError(`Invalid MonMode: ${ev.mode}, expected 0..15`) + } + let resolvedMode = ev.mode + if (unit.unitType === 'player' && resolvedMode === 1 && isTownLevel(this.areaId)) { + resolvedMode = 5 + } + this.clearUnitCastState(unit) + const now = this.resolveEventNowMs(nowMs) + unit.mode = resolvedMode + if (resolvedMode !== 2 && resolvedMode !== 3 && resolvedMode !== 6 && resolvedMode !== 15) { + unit.moveMode = 'stop' + } + unit.modeStartedAtMs = now + unit.modeElapsedMs = 0 + if (unit.unitType === 'player' && resolvedMode === 4) { + const speedInfo = this.resolvePlayerAnimSpeed(4, unit.id) + unit.modeDurationMs = speedInfo.durationMs + unit.modeUntilMs = now + speedInfo.durationMs + unit.animStepSpeed256 = speedInfo.stepSpeed256 + unit.animEffRate = speedInfo.effRate + } else if (unit.unitType === 'monster' && resolvedMode === 3) { + unit.modeDurationMs = 240 + unit.modeUntilMs = now + 240 + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + } else { + unit.modeDurationMs = undefined + unit.modeUntilMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + } } if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) { unit.x = ev.at.x unit.y = ev.at.y } + if (ev.hitClass !== undefined) { + unit.hitClass = ev.hitClass + } if (ev.lifePct !== undefined) { unit.lifePct = ev.lifePct } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { - if (ev.mode === 0 || ev.mode === 16 || ev.mode === 17 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { + if (ev.mode === 0 || ev.mode === 17 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { this._isSelfDead = true this._self.isDead = true } @@ -2503,10 +2526,15 @@ export class ClientWorld { } case 'CorpseAssign': { - const ownerId = ev.ownerId ?? ev.unitId ?? 0 - if ((ownerId === this.selfId || this.selfId === 0) && (ev.assign ?? 1) !== 0) { - this._isSelfDead = true - this._self.isDead = true + const ownerId = Number(ev.ownerId ?? 0) + const corpseId = Number(ev.corpseId ?? 0) + const assign = Number(ev.assign ?? 1) + if (corpseId !== 0) { + if (assign !== 0) { + this._corpses.set(corpseId, ownerId) + } else { + this._corpses.delete(corpseId) + } } break } @@ -2515,19 +2543,6 @@ export class ClientWorld { const rawUnitId = ev.unitId ?? ev.playerId ?? ev.subjectId const rawOtherId = ev.otherId ?? ev.objectId const rawRel = ev.relation ?? ev.state ?? ev.relations - // 0x74 / 0x8E CorpseAssign for local player - if ( - ev.kind === 'relation' && - rawOtherId !== undefined && - rawRel === 1 && - (rawUnitId === this.selfId || (this.selfId === 0 && rawUnitId === rawOtherId)) - ) { - this._isSelfDead = true - this._self.isDead = true - if (!this._party.has(rawOtherId)) { - break - } - } // 0x7F PartyMemberState for pets / hirelings (isPlayer === 0) if (ev.kind === 'memberState' && (ev.isPlayer === false || ev.relation === 0)) { @@ -2899,6 +2914,7 @@ export class ClientWorld { this._waypointUnitId = null this._quests = new Uint16Array(48) this._party.clear() + this._corpses.clear() this._trade.active = false this._trade.lastAction = 0 this._trade.partnerId = undefined diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index bad8e32..04dad3f 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -38,23 +38,44 @@ function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 's return 'walk' } -/** 0x0D PlayerStop (13B, D2Client.dll 0x6fb5f590) */ +export function map0x0DCodeToPlrMode(rawCode: number): number { + switch (rawCode) { + case 0x08: + return 0 // DT (Death) + case 0x09: + return 17 // DD (Dead) + case 0x06: + return 4 // GH (Get Hit) + case 0x12: + return 9 // BL (Block) + case 0x14: + return 19 // KB (Knockback) + case 0x07: + return 1 // NU (Neutral/Idle) + default: + return rawCode <= 19 ? rawCode : 1 + } +} + +/** 0x0D PlayerStop (13B, D2Client.dll 0x6fb5f590, D2MOO PlrMsg.cpp:85-107 stru_6FD30708) */ export function decode0x0DPlayerStop(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x0D const unitType = clampUnitType(r.u8()) const id = r.u32LE() - r.u8() // unknown + const rawCode = r.u8() const x = r.u16LE() const y = r.u16LE() r.u8() // unknown - r.u8() // lifePct + const lifePct = r.u8() + const mode = map0x0DCodeToPlrMode(rawCode) return { - type: 'UnitMove', + type: 'UnitMode', unitType, id, - mode: 'stop', - to: { x, y }, + mode, + at: { x, y }, + lifePct, } } diff --git a/src/netproto/d2gs/s2c/party.ts b/src/netproto/d2gs/s2c/party.ts index 196a369..dec0a74 100644 --- a/src/netproto/d2gs/s2c/party.ts +++ b/src/netproto/d2gs/s2c/party.ts @@ -85,14 +85,13 @@ export function decode0x74PlayerCorpseAssign(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x74 const assign = r.u8() - const unitId = r.u32LE() - const otherId = r.u32LE() + const ownerId = r.u32LE() + const corpseId = r.u32LE() return { - type: 'Party', - kind: 'relation', - unitId, - otherId, - relation: assign, + type: 'CorpseAssign', + assign, + ownerId, + corpseId, } } @@ -183,14 +182,13 @@ export function decode0x8ECorpseAssign(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x8E const assign = r.u8() - const unitId = r.u32LE() - const otherId = r.u32LE() + const ownerId = r.u32LE() + const corpseId = r.u32LE() return { - type: 'Party', - kind: 'relation', - unitId, - otherId, - relation: assign, + type: 'CorpseAssign', + assign, + ownerId, + corpseId, } } diff --git a/src/netproto/d2gs/s2c/skills.ts b/src/netproto/d2gs/s2c/skills.ts index 3176eb5..1b6031f 100644 --- a/src/netproto/d2gs/s2c/skills.ts +++ b/src/netproto/d2gs/s2c/skills.ts @@ -16,6 +16,7 @@ import { ByteReader } from '../../core/byte-reader.ts' import type { Hand, SkillLevel, UnitType } from '../../domain/ids.ts' import type { ServerEvent } from '../../domain/server-event.ts' +import { resolveMonsterCmdMode } from './units.ts' function clampUnitType(raw: number): UnitType { return (raw >= 0 && raw <= 5 ? raw : 0) as UnitType @@ -113,22 +114,24 @@ export function decode0x4DSkillOnLocation(pkt: Uint8Array): ServerEvent { } } -/** 0x6C NPCAttack (16B, D2Client.dll 0x6fb5f750) */ +/** 0x6C NPCAttack (16B, D2Client.dll 0x6fb5f750, D2MOO MonsterMsg.cpp:318-325) */ export function decode0x6CNpcAttack(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6C - const casterId = r.u32LE() - const skill = r.u16LE() - const targetId = r.u32LE() - const targetType = clampUnitType(r.u8()) + const id = r.u32LE() + const rawCmd = r.u8() + clampUnitType(r.u8()) // targetType + r.u32LE() // targetId + r.u8() // direction const x = r.u16LE() const y = r.u16LE() + const mode = resolveMonsterCmdMode(rawCmd) return { - type: 'SkillCast', - caster: { unitType: 1, id: casterId }, - skill, - level: 1, - target: targetId !== 0 ? { unitType: targetType, id: targetId } : { x, y }, + type: 'UnitMode', + unitType: 1, + id, + mode, + at: { x, y }, } } diff --git a/src/netproto/d2gs/s2c/units.ts b/src/netproto/d2gs/s2c/units.ts index ab420f9..431c2aa 100644 --- a/src/netproto/d2gs/s2c/units.ts +++ b/src/netproto/d2gs/s2c/units.ts @@ -50,19 +50,21 @@ export function decode0x0ARemoveUnit(pkt: Uint8Array): ServerEvent { } } -/** 0x0C NPCGetHit (9B, D2Client.dll 0x6fb5f5f0) */ +/** 0x0C NPCGetHit (9B, D2Client.dll 0x6fb5f5f0, D2MOO SCmd.cpp:190-202 & MonsterMsg.cpp:94) */ export function decode0x0CNpcGetHit(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x0C const unitType = clampUnitType(r.u8()) const id = r.u32LE() - const mode = r.u16LE() - const lifePct = r.u8() + r.u8() // unk0x06 (19) + const hitClass = r.u8() + const rawLife = r.u8() + const lifePct = rawLife & 0x7f return { type: 'UnitMode', unitType, id, - mode, + hitClass, lifePct, } } @@ -163,7 +165,7 @@ export const MONSTER_CMD_TO_MODE: readonly number[] = [ 11, // 0x1D: S2 (with target) ] -function resolveMonsterCmdMode(rawCmd: number): number { +export function resolveMonsterCmdMode(rawCmd: number): number { const mapped = MONSTER_CMD_TO_MODE[rawCmd] return mapped !== undefined ? mapped : rawCmd } @@ -206,7 +208,6 @@ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { y, mode: 1, }, - isSelf: x !== 0 || y !== 0, } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index ed4cfe8..cf2a224 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -77,10 +77,17 @@ export type ServerEvent = readonly type: 'UnitMode' readonly unitType: UnitType readonly id: number - readonly mode: number + readonly mode?: number | undefined readonly at?: Pt | undefined + readonly hitClass?: number | undefined readonly lifePct?: number | undefined } + | { + readonly type: 'CorpseAssign' + readonly assign: number + readonly ownerId: number + readonly corpseId: number + } | { readonly type: 'SkillCast' readonly caster: UnitRef diff --git a/tests/client/client-world-identity.test.ts b/tests/client/client-world-identity.test.ts new file mode 100644 index 0000000..cf84b2c --- /dev/null +++ b/tests/client/client-world-identity.test.ts @@ -0,0 +1,533 @@ +import { describe, expect, it } from 'vitest' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { decode0x0CNpcGetHit, decode0x59AssignPlayer } from '../../src/netproto/d2gs/s2c/units.ts' +import { decode0x74PlayerCorpseAssign, decode0x8ECorpseAssign } from '../../src/netproto/d2gs/s2c/party.ts' +import { decode0x0DPlayerStop } from '../../src/netproto/d2gs/s2c/movement.ts' +import { decode0x6CNpcAttack } from '../../src/netproto/d2gs/s2c/skills.ts' +import { ByteWriter } from '../../src/netproto/core/byte-writer.ts' + +describe('Batch 1 Phase 1 Client Parity (Issues #563, #564, #567, #569, #570)', () => { + describe('Issue #563: S2C 0x59 AssignPlayer packet & selfId stability', () => { + it('decode0x59AssignPlayer does not infer isSelf from non-zero coordinates', () => { + const w = new ByteWriter() + w.u8(0x59) + w.u32LE(2) // dwUnitId + w.u8(0) // charClass = Amazon + w.fixedString('RemotePlayer', 16) + w.u16LE(5105) // x != 0 + w.u16LE(5060) // y != 0 + const pkt = w.toUint8Array() + + const ev = decode0x59AssignPlayer(pkt) + expect(ev.type).toBe('UnitAssign') + if (ev.type === 'UnitAssign') { + expect(ev.unit.id).toBe(2) + expect(ev.unit.name).toBe('RemotePlayer') + expect(ev.unit.x).toBe(5105) + expect(ev.unit.y).toBe(5060) + // Must NOT set isSelf to true just because coordinates are non-zero! + expect(ev.isSelf).toBeUndefined() + } + }) + + it('ClientWorld preserves local selfId and self state when secondary player 0x59 packet arrives', () => { + const world = new ClientWorld() + world.applyServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x12345678, + areaId: 1, // Town + }) + + // Local player initial spawn + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 0, + id: 100, + classId: 1, // Sorceress + name: 'LocalHero', + x: 5100, + y: 5050, + }, + }) + + expect(world.selfId).toBe(100) + expect(world.self.unitId).toBe(100) + expect(world.self.name).toBe('LocalHero') + expect(world.self.charClass).toBe(1) + expect(world.self.x).toBe(5100) + expect(world.self.y).toBe(5050) + + // Secondary player arrives with non-zero coordinates + const w59 = new ByteWriter() + w59.u8(0x59) + w59.u32LE(200) + w59.u8(3) // Necromancer + w59.fixedString('InvadingPlayer', 16) + w59.u16LE(5180) + w59.u16LE(5200) + + const ev59 = decode0x59AssignPlayer(w59.toUint8Array()) + world.applyServerEvent(ev59) + + // selfId and local player MUST NOT BE OVERWRITTEN + expect(world.selfId).toBe(100) + expect(world.self.unitId).toBe(100) + expect(world.self.name).toBe('LocalHero') + expect(world.self.charClass).toBe(1) + expect(world.self.x).toBe(5100) + expect(world.self.y).toBe(5050) + + // Secondary player is correctly stored as remote unit in world.units + const remoteUnit = world.getUnit(0, 200) + expect(remoteUnit).toBeDefined() + expect(remoteUnit?.id).toBe(200) + expect(remoteUnit?.name).toBe('InvadingPlayer') + expect(remoteUnit?.classId).toBe(3) + expect(remoteUnit?.x).toBe(5180) + expect(remoteUnit?.y).toBe(5200) + }) + }) + + describe('Issue #564: Corpse assignment packets 0x8E / 0x74', () => { + it('decode0x74PlayerCorpseAssign and decode0x8ECorpseAssign return CorpseAssign event', () => { + // 0x74 [opcode:1B][assign:1B][ownerId:4B][corpseId:4B] + const w74 = new ByteWriter() + w74.u8(0x74) + w74.u8(1) + w74.u32LE(100) // ownerId + w74.u32LE(888) // corpseId + const ev74 = decode0x74PlayerCorpseAssign(w74.toUint8Array()) + + expect(ev74).toEqual({ + type: 'CorpseAssign', + assign: 1, + ownerId: 100, + corpseId: 888, + }) + + // 0x8E [opcode:1B][assign:1B][ownerId:4B][corpseId:4B] + const w8e = new ByteWriter() + w8e.u8(0x8e) + w8e.u8(1) + w8e.u32LE(100) + w8e.u32LE(999) + const ev8e = decode0x8ECorpseAssign(w8e.toUint8Array()) + + expect(ev8e).toEqual({ + type: 'CorpseAssign', + assign: 1, + ownerId: 100, + corpseId: 999, + }) + }) + + it('ClientWorld stores corpse in world.corpses and NEVER marks living player dead or leaks into party', () => { + const world = new ClientWorld() + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 100, classId: 0, name: 'AlivePlayer', x: 5000, y: 5000 }, + }) + + expect(world.isSelfDead).toBe(false) + expect(world.self.isDead).toBe(false) + + // Receive 0x8E CorpseAssign for local player + world.applyServerEvent({ + type: 'CorpseAssign', + assign: 1, + ownerId: 100, + corpseId: 999, + }) + + // Corpse tracked in world.corpses map + expect(world.corpses.get(999)).toBe(100) + + // Living player MUST REMAIN ALIVE + expect(world.isSelfDead).toBe(false) + expect(world.self.isDead).toBe(false) + + // Must NOT pollute party map + expect(world.party.has(999)).toBe(false) + expect(world.party.size).toBe(0) + + // Unassign corpse (assign = 0) + world.applyServerEvent({ + type: 'CorpseAssign', + assign: 0, + ownerId: 100, + corpseId: 999, + }) + + expect(world.corpses.has(999)).toBe(false) + expect(world.isSelfDead).toBe(false) + }) + }) + + describe('Issue #567: S2C 0x0D movement packet decoding & mode lifecycle', () => { + it('decode0x0DPlayerStop maps action codes to PlrMode (DT, DD, GH, BL, KB, NU) and preserves lifePct', () => { + // 0x08 -> DT (0) + const wDt = new ByteWriter() + wDt.u8(0x0d).u8(0).u32LE(1).u8(0x08).u16LE(5100).u16LE(5200).u8(0).u8(0) + const evDt = decode0x0DPlayerStop(wDt.toUint8Array()) + expect(evDt).toEqual({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 0, // DT + at: { x: 5100, y: 5200 }, + lifePct: 0, + }) + + // 0x09 -> DD (17) + const wDd = new ByteWriter() + wDd.u8(0x0d).u8(0).u32LE(1).u8(0x09).u16LE(5100).u16LE(5200).u8(0).u8(0) + const evDd = decode0x0DPlayerStop(wDd.toUint8Array()) + expect(evDd).toEqual({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 17, // DD + at: { x: 5100, y: 5200 }, + lifePct: 0, + }) + + // 0x06 -> GH (4) + const wGh = new ByteWriter() + wGh.u8(0x0d).u8(0).u32LE(1).u8(0x06).u16LE(5100).u16LE(5200).u8(0).u8(85) + const evGh = decode0x0DPlayerStop(wGh.toUint8Array()) + expect(evGh).toEqual({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 4, // GH + at: { x: 5100, y: 5200 }, + lifePct: 85, + }) + + // 0x12 -> BL (9) + const wBl = new ByteWriter() + wBl.u8(0x0d).u8(0).u32LE(1).u8(0x12).u16LE(5100).u16LE(5200).u8(0).u8(95) + const evBl = decode0x0DPlayerStop(wBl.toUint8Array()) + expect(evBl).toEqual({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 9, // BL + at: { x: 5100, y: 5200 }, + lifePct: 95, + }) + + // 0x14 -> KB (19) + const wKb = new ByteWriter() + wKb.u8(0x0d).u8(0).u32LE(1).u8(0x14).u16LE(5100).u16LE(5200).u8(0).u8(70) + const evKb = decode0x0DPlayerStop(wKb.toUint8Array()) + expect(evKb).toEqual({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 19, // KB + at: { x: 5100, y: 5200 }, + lifePct: 70, + }) + + // 0x07 -> NU (1) + const wNu = new ByteWriter() + wNu.u8(0x0d).u8(0).u32LE(1).u8(0x07).u16LE(5100).u16LE(5200).u8(0).u8(100) + const evNu = decode0x0DPlayerStop(wNu.toUint8Array()) + expect(evNu).toEqual({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 1, // NU + at: { x: 5100, y: 5200 }, + lifePct: 100, + }) + }) + + it('ClientWorld handles 0x0D: resolves town idle to TN=5 and triggers death on DT=0 or DD=17', () => { + const world = new ClientWorld() + world.applyServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x1234, + areaId: 1, // Town + }) + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 0, name: 'Hero', x: 5000, y: 5000 }, + }) + + // 0x0D action code 0x07 (NU=1) in town resolves to TN=5 + world.applyServerEvent({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 1, + at: { x: 5010, y: 5010 }, + lifePct: 100, + }) + expect(world.getUnit(0, 1)?.mode).toBe(5) + expect(world.isSelfDead).toBe(false) + + // 0x0D DT=0 triggers death + world.applyServerEvent({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 0, + at: { x: 5010, y: 5010 }, + lifePct: 0, + }) + expect(world.isSelfDead).toBe(true) + expect(world.self.isDead).toBe(true) + expect(world.getUnit(0, 1)?.mode).toBe(0) + }) + + it('PlrMode 16 (S4 Left Hand Throw) is not dead and does not block movement', () => { + const world = new ClientWorld() + world.applyServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x1234, + areaId: 2, // Wilderness + }) + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 0, name: 'Thrower', x: 5000, y: 5000 }, + }) + + // Set mode 16 (S4) + world.applyServerEvent({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 16, + lifePct: 100, + }) + + expect(world.getUnit(0, 1)?.mode).toBe(16) + expect(world.isSelfDead).toBe(false) + expect(world.self.isDead).toBe(false) + + // UnitMove from mode 16 transitions to walk (2 in wilderness) without being blocked by death check + world.applyServerEvent({ + type: 'UnitMove', + unitType: 'player', + id: 1, + mode: 'walk', + to: { x: 5020, y: 5020 }, + }) + expect(world.getUnit(0, 1)?.mode).toBe(2) // WL=2 + }) + }) + + describe('Issue #569: S2C 0x6C monster attack command decoding', () => { + it('decode0x6CNpcAttack extracts byte 5 command code and maps to MonMode via MONSTER_CMD_TO_MODE', () => { + // 0x6C: [opcode:1B][dwUnitId:4B][rawCmd:1B][targetType:1B][targetId:4B][dir:1B][x:2B][y:2B] + // rawCmd = 0x0A -> A1 (mode 4) + const w0a = new ByteWriter() + w0a.u8(0x6c) + w0a.u32LE(50) // monster id + w0a.u8(0x0a) // rawCmd = 0x0A -> A1 (mode 4) + w0a.u8(0) // targetType = player + w0a.u32LE(1) // targetId + w0a.u8(12) // direction + w0a.u16LE(5080) + w0a.u16LE(5090) + + const ev0a = decode0x6CNpcAttack(w0a.toUint8Array()) + expect(ev0a.type).toBe('UnitMode') + if (ev0a.type === 'UnitMode') { + expect(ev0a.unitType).toBe(1) + expect(ev0a.id).toBe(50) + expect(ev0a.mode).toBe(4) // MonMode A1 + expect(ev0a.at).toEqual({ x: 5080, y: 5090 }) + } + + // rawCmd = 0x10 -> A2 (mode 5) + const w10 = new ByteWriter() + w10.u8(0x6c) + w10.u32LE(50) + w10.u8(0x10) // rawCmd = 0x10 -> A2 (mode 5) + w10.u8(0) + w10.u32LE(1) + w10.u8(12) + w10.u16LE(5080) + w10.u16LE(5090) + + const ev10 = decode0x6CNpcAttack(w10.toUint8Array()) + // MUST NOT be decoded as player SkillCast (Exploding Arrow)! + expect(ev10.type).toBe('UnitMode') + if (ev10.type === 'UnitMode') { + expect(ev10.unitType).toBe(1) + expect(ev10.id).toBe(50) + expect(ev10.mode).toBe(5) // MonMode A2 (NOT Amazon skill 16!) + expect(ev10.at).toEqual({ x: 5080, y: 5090 }) + } + }) + + it('ClientWorld processes 0x6C UnitMode without corrupting player skills', () => { + const world = new ClientWorld() + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Hero', x: 5000, y: 5000 }, + }) + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { unitType: 1, id: 50, classId: 10, name: 'Fallen', x: 5050, y: 5050, mode: 1 }, + }) + + // Send 0x6C attack event for monster + world.applyServerEvent({ + type: 'UnitMode', + unitType: 1, + id: 50, + mode: 4, // A1 + at: { x: 5052, y: 5052 }, + }) + + const monster = world.getUnit(1, 50) + expect(monster?.mode).toBe(4) + expect(monster?.x).toBe(5052) + expect(monster?.y).toBe(5052) + + // Player state and skill remain completely unaffected + expect(world.selfId).toBe(1) + expect(world.isSelfDead).toBe(false) + }) + }) + + describe('Issue #570: S2C 0x0C packet decoding & mode range checks', () => { + it('decode0x0CNpcGetHit parses hitClass and lifePct without fabricating u16 mode 531', () => { + // 0x0C: [opcode:1B][unitType:1B][guid:4B][unk0x06:1B][hitClass:1B][rawLife:1B] + const w = new ByteWriter() + w.u8(0x0c) + w.u8(1) // monster + w.u32LE(77) // id + w.u8(19) // unk0x06 + w.u8(2) // hitClass = 2 + w.u8(0x45) // rawLife = 69 (lifePct = 69 & 0x7f = 69) + + const ev = decode0x0CNpcGetHit(w.toUint8Array()) + expect(ev).toEqual({ + type: 'UnitMode', + unitType: 1, + id: 77, + hitClass: 2, + lifePct: 69, + }) + // mode MUST be undefined (not 531!) + if (ev.type === 'UnitMode') { + expect(ev.mode).toBeUndefined() + } + }) + + it('ClientWorld updates hitClass and lifePct on 0x0C without resetting active attack mode or castState', () => { + const world = new ClientWorld() + world.applyServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { unitType: 1, id: 77, classId: 10, name: 'Fallen', x: 5000, y: 5000, mode: 4 }, + }) + + const monster = world.getUnit(1, 77)! + // Simulate an in-progress attack castState + monster.castState = { + skillId: 0, + startedAtMs: 1000, + untilMs: 2000, + targetX: 5010, + targetY: 5010, + mode: 4, + } + + // Apply 0x0C hitClass and lifePct update + world.applyServerEvent({ + type: 'UnitMode', + unitType: 1, + id: 77, + hitClass: 2, + lifePct: 65, + }) + + expect(monster.hitClass).toBe(2) + expect(monster.lifePct).toBe(65) + // Active attack mode and castState MUST NOT be cancelled + expect(monster.mode).toBe(4) + expect(monster.castState).toBeDefined() + expect(monster.castState?.untilMs).toBe(2000) + }) + + it('ClientWorld enforces fail-fast range checks: MonMode 0..15, PlrMode 0..19', () => { + const world = new ClientWorld() + + // PlrMode 20 is out of bounds (0..19) + expect(() => { + world.applyServerEvent({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 20, + }) + }).toThrow(RangeError) + + // PlrMode -1 is out of bounds + expect(() => { + world.applyServerEvent({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: -1, + }) + }).toThrow(RangeError) + + // MonMode 16 is out of bounds (0..15) + expect(() => { + world.applyServerEvent({ + type: 'UnitMode', + unitType: 1, + id: 50, + mode: 16, + }) + }).toThrow(RangeError) + + // MonMode -1 is out of bounds + expect(() => { + world.applyServerEvent({ + type: 'UnitMode', + unitType: 1, + id: 50, + mode: -1, + }) + }).toThrow(RangeError) + + // Valid PlrMode 19 passes + expect(() => { + world.applyServerEvent({ + type: 'UnitMode', + unitType: 0, + id: 1, + mode: 19, + }) + }).not.toThrow() + + // Valid MonMode 15 passes + expect(() => { + world.applyServerEvent({ + type: 'UnitMode', + unitType: 1, + id: 50, + mode: 15, + }) + }).not.toThrow() + }) + }) +}) diff --git a/tests/e2e-netproto/tier1-feature-coverage.test.ts b/tests/e2e-netproto/tier1-feature-coverage.test.ts index b07a9c2..c3cbf56 100644 --- a/tests/e2e-netproto/tier1-feature-coverage.test.ts +++ b/tests/e2e-netproto/tier1-feature-coverage.test.ts @@ -725,9 +725,10 @@ describe('Tier 1 — F7: D2GS S→C Decoders (Handshake, Map, Entity, Movement, w0d.u8(0x00) w0d.u8(100) const stopEv = decode0x0DPlayerStop(w0d.toUint8Array()) - expect(stopEv.type).toBe('UnitMove') - if (stopEv.type === 'UnitMove') { - expect(stopEv.to).toEqual({ x: 5100, y: 5200 }) + expect(stopEv.type).toBe('UnitMode') + if (stopEv.type === 'UnitMode') { + expect(stopEv.at).toEqual({ x: 5100, y: 5200 }) + expect(stopEv.lifePct).toBe(100) } const w15 = new ByteWriter() diff --git a/tools/d2-bot.ts b/tools/d2-bot.ts index 3a4433d..e153d20 100644 --- a/tools/d2-bot.ts +++ b/tools/d2-bot.ts @@ -878,7 +878,7 @@ async function runAuditLevelsSubcommand(options: { } else if (ev.type === 'UnitMode') { const wpMap = waypointObjectsByAct.get(currentActIndex) const existingWp = wpMap?.get(ev.id) - if (existingWp) { + if (existingWp && ev.mode !== undefined) { existingWp.mode = ev.mode } } else if (