Fix S2C 0x59 identity stability and 0x8E corpse packets (Closes #563, #564, #567, #569, #570)

This commit is contained in:
troytt 2026-10-02 15:00:35 +00:00
parent 7658ad94f1
commit f8c938f866
11 changed files with 694 additions and 84 deletions

View File

@ -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

View File

@ -106,6 +106,7 @@ export class ClientUnit {
return { ...this._turnState }
}
lifePct: number
hitClass?: number | undefined
flags: number
warpId?: number | undefined
objectState?: number | undefined

View File

@ -317,6 +317,7 @@ export class ClientWorld {
private _waypointUnitId: number | null = null
private _quests = new Uint16Array(48)
private readonly _party = new Map<number, PartyMemberState>()
private readonly _corpses = new Map<number, number>()
private readonly _trade: TradeSessionState = {
active: false,
lastAction: 0,
@ -1176,6 +1177,10 @@ export class ClientWorld {
return this._party
}
get corpses(): ReadonlyMap<number, number> {
return this._corpses
}
get trade(): Readonly<TradeSessionState> {
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

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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 },
}
}

View File

@ -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,
}
}

View File

@ -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

View File

@ -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()
})
})
})

View File

@ -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()

View File

@ -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 (