From 59435527b0d19a168ab849c04e02c869dd3e6323 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:19:02 +0000 Subject: [PATCH] fix(client/netproto): align 1.13c monster/NPC mode decoding and prevent initial death state (#551) - Decode the initial 4-bit bitstream in 0xAC AssignNPC as monster mode nMode (D2Game.dll 0x6fcacac9, D2Client.dll 0x6fb5e462) and seed initial 1.13c facing via 0x6ac690c5 (D2Client.dll 0x6fafd512) - Map 0x69/0x6A/0x6B state command byte pkt[5] via 1.13c D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE, inverse of D2Game.dll 0x6fd1a718) and only apply pkt[10]/pkt[11] as lifePct on 0x06 (GH) or 0x08/0x09 (DT/DD) so non-death commands (e.g. Charsi 0x6B anvil sequence 0x0C) never zero out lifePct - Default alive monsters and players to mode=1 (NU) instead of mode=0 (DT) across ClientUnit, ClientWorld, OnlineSession, buildSceneDrawables, and drawUnitSprite - Transition monster/player modes across UnitMove and OnlineSession interpolator ticks (walk=2, run=15/3, knockback=9/19, stop=1) - Add 1.13c unit and live .d2cap regression tests Closes #551 --- src/client/motion/interpolate.ts | 4 +- src/client/scene/world-renderer.ts | 2 +- src/client/session/online-session.ts | 29 ++++- src/client/view/unit-drawables.ts | 2 +- src/client/world/client-unit.ts | 30 ++++- src/client/world/client-world.ts | 54 ++++++++- src/netproto/d2gs/s2c/movement.ts | 15 ++- src/netproto/d2gs/s2c/units.ts | 158 +++++++++++++++++++++------ tests/client/client-world.test.ts | 94 ++++++++++++++++ tests/netproto/d2gs-s2c.test.ts | 112 +++++++++++++++++++ 10 files changed, 457 insertions(+), 43 deletions(-) diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 614d085..ed187bf 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -88,7 +88,9 @@ export function resolveRemoteMoveSpeed(options: { const running = options.running ?? (options.moveType !== undefined - ? options.moveType === 0x17 || (options.moveType & 0x02) !== 0 + ? options.moveType === 0x17 || + options.moveType === 0x18 || + (options.moveType & 0x02) !== 0 : false) if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { return { running, speedSubTilesPerSec: options.speedSubTilesPerSec } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..0d9c3f5 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -480,7 +480,7 @@ function drawSceneUnitDrawable( : art.clips?.dt ? 'dt' : 'nu' - : (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) + : (MONSTER_MODE_TO_CLIP[unit.mode || 1] ?? (isMoving ? 'wl' : 'nu')) const clip = art.clips?.[requestedClipKey] ?? (isMoving ? art.clips?.wl : undefined) ?? diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..f958ee1 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1303,6 +1303,15 @@ export class OnlineSession { unit.x = state.x unit.y = state.y unit.direction = state.dir64 + const isMonsterMoveMode = + unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) + const isPlayerMoveMode = + unit.unitType === 'player' && + (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19) + if (!state.moving && unit.lifePct > 0 && (isMonsterMoveMode || isPlayerMoveMode)) { + unit.mode = 1 + unit.moveMode = 'stop' + } } } } @@ -1437,7 +1446,25 @@ export class OnlineSession { const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0) const castState = !isCorpse ? u.castState : undefined - const mode = castState ? castState.mode || u.mode : u.mode + const mode = isCorpse + ? u.mode === 0 + ? 0 + : 12 + : castState + ? castState.mode || u.mode || 1 + : interp?.moving && (u.unitType === 'monster' || u.unitType === 'player') + ? u.moveMode === 'knockback' + ? u.unitType === 'monster' + ? 9 + : 19 + : interp.running + ? u.unitType === 'monster' + ? 15 + : 3 + : 2 + : u.unitType === 'monster' || u.unitType === 'player' + ? u.mode || 1 + : u.mode const targetX = castState?.targetX ?? u.targetX const targetY = castState?.targetY ?? u.targetY diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0650ee8..2920138 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -487,7 +487,7 @@ export function buildSceneDrawables( const effectiveTargetY = unit.targetY ?? activeCastState?.targetY const effectiveMode = activeCastState ? activeCastState.mode || unit.mode || 10 - : (unit.mode ?? 0) + : (unit.mode ?? (unit.kind === 'player' || unit.kind === 'monster' ? (isCorpse ? 12 : 1) : 0)) const animMode = unit.animMode ?? (!isCorpse && (effectiveMode === 7 || effectiveMode === 4) diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..4ace995 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -73,9 +73,17 @@ export class ClientUnit { this.name = snapshot.name this.x = snapshot.x this.y = snapshot.y - this.mode = snapshot.mode ?? 0 - this.direction = snapshot.direction ?? 0 this.lifePct = snapshot.lifePct ?? 128 + const defaultMode = + this.unitType === 'monster' + ? this.lifePct <= 0 + ? 12 + : 1 + : this.unitType === 'player' + ? 1 + : 0 + this.mode = snapshot.mode ?? defaultMode + this.direction = snapshot.direction ?? 0 this.flags = snapshot.flags ?? 0 this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined) this.objectState = snapshot.objectState @@ -131,6 +139,13 @@ export class ClientUnit { } if (snapshot.lifePct !== undefined) { this.lifePct = snapshot.lifePct + if (snapshot.mode === undefined && this.unitType === 'monster') { + if (this.lifePct <= 0) { + this.mode = 12 + } else if (this.mode === 0 || this.mode === 12) { + this.mode = 1 + } + } } if (snapshot.flags !== undefined) { this.flags = snapshot.flags @@ -183,6 +198,17 @@ export class ClientUnit { this.y = from.y } this.moveMode = mode + if (!this.dead && (this.unitType === 'monster' || this.unitType === 'player')) { + if (mode === 'stop') { + this.mode = 1 + } else if (mode === 'run') { + this.mode = this.unitType === 'monster' ? 15 : 3 + } else if (mode === 'knockback') { + this.mode = this.unitType === 'monster' ? 9 : 19 + } else { + this.mode = 2 + } + } this.targetX = to.x this.targetY = to.y this.targetUnit = target diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..778eba1 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -503,6 +503,17 @@ export class ClientWorld { } else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') { const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1) const uId = Number(ev.unitId ?? ev.id ?? 1) + const lifePct = ev.maxHp + ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) + : 128 + const defaultMode = + uType === 'monster' + ? lifePct <= 0 + ? 12 + : 1 + : uType === 'player' + ? 1 + : 0 ev = { type: 'UnitAssign', isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)), @@ -513,7 +524,8 @@ export class ClientWorld { name: ev.name ?? ev.code, x: Number(ev.x ?? 0), y: Number(ev.y ?? 0), - lifePct: ev.maxHp ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) : 128, + lifePct, + mode: ev.mode !== undefined ? Number(ev.mode) : defaultMode, }, } } else if (ev.type === 'EntityRemove') { @@ -571,7 +583,7 @@ export class ClientWorld { type: 'UnitMode', unitType: ev.unitType ?? 1, id: Number(ev.unitId ?? ev.id ?? 0), - mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)), + mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 3)), lifePct: Number(ev.lifePct ?? 0), } } @@ -694,6 +706,34 @@ export class ClientWorld { unit.targetX = ev.to.x unit.targetY = ev.to.y unit.targetUnit = ev.target + if (ev.mode) { + unit.moveMode = ev.mode + } + if (unit.unitType === 'monster' && unit.lifePct > 0 && unit.mode !== 0 && unit.mode !== 12) { + unit.mode = + ev.mode === 'stop' + ? 1 + : ev.mode === 'run' + ? 15 + : ev.mode === 'knockback' + ? 9 + : 2 + if (ev.mode !== 'stop') { + unit.castState = undefined + } + } else if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { + unit.mode = + ev.mode === 'stop' + ? 1 + : ev.mode === 'run' + ? 3 + : ev.mode === 'knockback' + ? 19 + : 2 + if (ev.mode !== 'stop') { + unit.castState = undefined + } + } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { this._self.x = ev.from.x @@ -735,6 +775,16 @@ export class ClientWorld { const uId = Number(ev.id ?? ev.unitId ?? 0) const unit = this.ensureUnit(ev.unitType, uId) unit.mode = ev.mode + if ( + ev.mode === 0 || + ev.mode === 1 || + ev.mode === 3 || + ev.mode === 12 || + ev.mode === 16 || + ev.mode === 17 + ) { + unit.castState = undefined + } if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) { unit.x = ev.at.x unit.y = ev.at.y diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index cdc03e0..244317d 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -14,9 +14,18 @@ function clampUnitType(raw: number): UnitType { } function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' { - // In D2Client.dll: 0x17 / 0x02 / 0x04 / bit 0x10 = run, 0x20 = knockback - if (rawMoveType === 0x20 || rawMoveType === 0x09) return 'knockback' - if (rawMoveType === 0x17 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) return 'run' + // In D2Client.dll 0x6fb82e68: 0x17 (0x67) / 0x18 (0x68) / 0x02 / 0x04 / bit 0x10 = run, 0x20 / 0x0e / 0x0f = knockback + if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x0e || rawMoveType === 0x0f) { + return 'knockback' + } + if ( + rawMoveType === 0x17 || + rawMoveType === 0x18 || + rawMoveType === 0x02 || + (rawMoveType & 0x02) !== 0 + ) { + return 'run' + } return 'walk' } diff --git a/src/netproto/d2gs/s2c/units.ts b/src/netproto/d2gs/s2c/units.ts index a99476c..2ed0d15 100644 --- a/src/netproto/d2gs/s2c/units.ts +++ b/src/netproto/d2gs/s2c/units.ts @@ -124,7 +124,68 @@ export function decode0x51AssignObject(pkt: Uint8Array): ServerEvent { } } -/** 0x59 AssignPlayer (26B, D2Client.dll 0x6fb5df20) */ +/** + * 1.13c D2Client.dll `0x6fb82e68` (`30` entries) mapping monster state-command byte (`pkt[5]` in + * `0x67`, `0x68`, `0x69`, `0x6A`, `0x6B`) to canonical `MonMode.txt` ID (`0..16`). + * Inverse of D2Game.dll `0x6fd1a718` (`g_aMonModePacketTable`). + */ +export const MONSTER_CMD_TO_MODE: readonly number[] = [ + 2, // 0x00: WL (0x68 walk) + 2, // 0x01: WL (0x67 walk) + 16, // 0x02: special + 16, // 0x03: special + 7, // 0x04: SC (Skill1 / Cast no target) + 7, // 0x05: SC (Skill1 / Cast with target) + 3, // 0x06: GH (Get Hit) + 1, // 0x07: NU (Neutral / 0x6D stop) + 0, // 0x08: DT (Death animation) + 12, // 0x09: DD (Dead corpse) + 4, // 0x0A: A1 (Attack 1 with target) + 4, // 0x0B: A1 (Attack 1 no target) + 8, // 0x0C: SQ (Sequence no target) + 8, // 0x0D: SQ (Sequence with target) + 9, // 0x0E: KB (Knockback no target) + 9, // 0x0F: KB (Knockback with target) + 5, // 0x10: A2 (Attack 2 with target) + 5, // 0x11: A2 (Attack 2 no target) + 6, // 0x12: BL (Block) + 16, // 0x13: special + 13, // 0x14: S2 (Skill 2) + 14, // 0x15: S3 (Skill 3) + 14, // 0x16: S3 (Skill 3) + 15, // 0x17: RN (0x67 run) + 15, // 0x18: RN (0x68 run) + 8, // 0x19: SQ (Sequence) + 10, // 0x1A: S1 (no target) + 10, // 0x1B: S1 (with target) + 11, // 0x1C: S2 (no target) + 11, // 0x1D: S2 (with target) +] + +function resolveMonsterCmdMode(rawCmd: number): number { + const mapped = MONSTER_CMD_TO_MODE[rawCmd] + return mapped !== undefined ? mapped : rawCmd +} + +/** + * In 1.13c (`D2Game.dll 0x6fd13620` & `D2Client.dll 0x6fafdc70`): + * - `pkt[10]` (`0x69`/`0x6B`) / `pkt[11]` (`0x6A`) carries `lifePct & 0x7f` ONLY when `rawCmd === 0x06` (`MONMODE_GETHIT`, `0x6fd13829` / `0x6fafe16c`). + * - When `rawCmd === 0x08` (`MONMODE_DEATH`) or `0x09` (`MONMODE_DEAD`), `D2Client.dll` (`0x6fafe298`) sets HP to `0`. + * - For all other commands (e.g. `0x0C` `MONMODE_SEQUENCE` when Charsi hammers the anvil), the byte is `0` on the wire and must NOT overwrite `unit.lifePct` with `0`. + */ +function resolveMonsterCmdLifePct(rawCmd: number, paramByte: number): number | undefined { + if (rawCmd === 0x08 || rawCmd === 0x09) { + return 0 + } + if (rawCmd === 0x06) { + if (paramByte === 0) return undefined + const masked = paramByte & 0x7f + return masked > 0 ? masked : 128 + } + return paramByte > 0 && paramByte <= 0x80 ? paramByte : undefined +} + +/** 0x59 AssignPlayer (26B, D2Client.dll 0x6fb5df20 -> 0x6fac9275) */ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x59 @@ -142,64 +203,72 @@ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { name, x, y, + mode: 1, }, isSelf: x !== 0 || y !== 0, } } -/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390) */ +/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390 -> 0x6fafdc70) */ export function decode0x69SetNpcMode(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x69 const id = r.u32LE() - const mode = r.u8() + const rawCmd = r.u8() const x = r.u16LE() const y = r.u16LE() - const lifePct = r.u8() - r.u8() + const paramByte = r.u8() + r.u8() // animSpeed + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), at: { x, y }, - lifePct, + ...(lifePct !== undefined ? { lifePct } : {}), } } -/** 0x6A NPCAction (12B, D2Client.dll 0x6fb5f340) */ +/** 0x6A NPCAction (12B, D2Client.dll 0x6fb5f340 -> 0x6fafdc70) */ export function decode0x6ANpcAction(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6A const id = r.u32LE() - const mode = r.u8() - r.u8() - r.u32LE() - r.u8() + const rawCmd = r.u8() + r.u8() // targetType + r.u32LE() // targetId + const paramByte = r.u8() + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), + ...(lifePct !== undefined ? { lifePct } : {}), } } -/** 0x6B NPCAction2 (16B, D2Client.dll 0x6fb5f7c0) */ +/** 0x6B NPCAction2 (16B, D2Client.dll 0x6fb5f7c0 -> 0x6fb5f390 -> 0x6fafdc70) */ export function decode0x6BNpcAction2(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6B const id = r.u32LE() - const mode = r.u8() - r.u32LE() + const rawCmd = r.u8() + r.u16LE() // targetX + r.u16LE() // targetY + const paramByte = r.u8() + r.u8() // animSpeed const x = r.u16LE() const y = r.u16LE() - r.u16LE() + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), at: { x, y }, + ...(lifePct !== undefined ? { lifePct } : {}), } } @@ -217,7 +286,15 @@ export function decode0x76UnitInSight(pkt: Uint8Array): ServerEvent { } } -/** 0xAC AssignNPC (variable pkt[12], D2Client.dll 0x6fb5e420) */ +/** + * 0xAC AssignNPC (variable pkt[12], D2Game.dll 0x6fcaca40 -> D2Client.dll 0x6fb5e420). + * + * Bitstream layout starting at offset 13 (`0x6fb5e462`): + * - 4 bits: initial monster mode `nMode` (`D2Game.dll 0x6fcacac9`: `0=DT`, `8=SQ`, `9=KB`, `12=DD`, + * or `1=NU` for all other alive modes; applied via `D2Common_11090` at `0x6fb5787c`/`0x6fafd345`) + * - 1 bit: `hasAnimComp` (`0x6fb5e47f`) + * - 1 bit: `hasMonsterFlags` (`0x6fb5e4fc`, followed by 5 boss flag bits when `hasAnimComp === 0`) + */ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0xAC @@ -228,27 +305,42 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { const lifePct = r.u8() const packetLen = r.u8() + let rawMode: number | undefined let flags = 0 if (packetLen > 13 && pkt.byteLength >= 13) { const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen))) - if (bitReader.remainingBits >= 6) { - bitReader.readBits(4) // initial facing direction - const hasAnimComp = bitReader.readBit() - // Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2). - // If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc: - if (hasAnimComp === 0 && bitReader.remainingBits >= 1) { - const hasMonsterFlags = bitReader.readBit() - if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) { - if (bitReader.readBit()) flags |= 0x04 // champion - if (bitReader.readBit()) flags |= 0x08 // unique - if (bitReader.readBit()) flags |= 0x02 // superunique - if (bitReader.readBit()) flags |= 0x10 // minion - if (bitReader.readBit()) flags |= 0x40 // ghostly + if (bitReader.remainingBits >= 4) { + rawMode = bitReader.readBits(4) // 4-bit initial monster mode (D2Client.dll 0x6fb5e462) + if (bitReader.remainingBits >= 1) { + const hasAnimComp = bitReader.readBit() + // Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2). + // If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc: + if (hasAnimComp === 0 && bitReader.remainingBits >= 1) { + const hasMonsterFlags = bitReader.readBit() + if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) { + if (bitReader.readBit()) flags |= 0x04 // champion + if (bitReader.readBit()) flags |= 0x08 // unique + if (bitReader.readBit()) flags |= 0x02 // superunique + if (bitReader.readBit()) flags |= 0x10 // minion + if (bitReader.readBit()) flags |= 0x40 // ghostly + } } } } } + const mode = + lifePct <= 0 + ? rawMode === 0 || rawMode === 12 + ? rawMode + : 12 + : rawMode !== undefined && rawMode > 0 && rawMode !== 12 + ? rawMode + : 1 + + // D2Client.dll 0x6fafd512..0x6fafd59c (D2CLIENT_InitMonsterUnit): initial facing seeded by unit id + const direction = Math.imul(id, 0x6ac690c5) & 0x3f + return { type: 'UnitAssign', unit: { @@ -257,6 +349,8 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { classId, x, y, + mode, + direction, lifePct, flags, }, diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 3c2f423..82581b7 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -678,4 +678,98 @@ describe('ClientWorld authoritative state mirror', () => { expect(world.containers.tradeGrid).toHaveLength(0) expect(world.containers.partnerTradeGrid).toHaveLength(0) }) + + it('Issue #551: initializes alive monsters/NPCs (e.g. Rogue Scout) in MONMODE_NEUTRAL (1) instead of MONMODE_DEATH (0) and transitions mode on UnitMove', () => { + const world = new ClientWorld() + + // Assign an alive camp Rogue Scout without an explicit mode field + world.apply({ + type: 'UnitAssign', + unit: { + unitType: 1, + id: 42, + classId: 564, // act1hire (Rogue Scout) + x: 5110, + y: 5060, + lifePct: 128, + }, + }) + const rogue = world.getUnit(1, 42)! + expect(rogue).toBeDefined() + expect(rogue.mode).toBe(1) // MONMODE_NEUTRAL (1), never MONMODE_DEATH (0) + + // Patrol walk (0x67 NPCMove) transitions mode to MONMODE_WALK (2) + world.apply({ + type: 'UnitMove', + unitType: 1, + id: 42, + mode: 'walk', + from: { x: 5110, y: 5060 }, + to: { x: 5116, y: 5064 }, + }) + expect(rogue.mode).toBe(2) + + // Patrol stop (0x6D NPCStop) transitions mode back to MONMODE_NEUTRAL (1) + world.apply({ + type: 'UnitMove', + unitType: 1, + id: 42, + mode: 'stop', + to: { x: 5116, y: 5064 }, + }) + expect(rogue.mode).toBe(1) + + // Dead monster spawned with lifePct=0 defaults to MONMODE_DEAD (12) + world.apply({ + type: 'UnitAssign', + unit: { + unitType: 1, + id: 99, + classId: 1, + x: 5200, + y: 5200, + lifePct: 0, + }, + }) + expect(world.getUnit(1, 99)?.mode).toBe(12) + }) + + it('Issue #551: live .d2cap Rogue Encampment captures never render alive camp NPCs (Rogue Scout, Akara, Charsi, Kashya, Warriv, Gheed) as corpses or mode=0 (DT)', async () => { + const { readFileSync } = await import('node:fs') + const { resolve } = await import('node:path') + const { OnlineSession } = await import('../../src/client/session/online-session.ts') + const { createSettingsStore } = await import('../../src/client/settings/settings-store.ts') + const { buildSceneDrawables } = await import('../../src/client/view/unit-drawables.ts') + + for (const fixture of [ + '02-d2gs-town-walk-chat.d2cap', + '03-d2gs-items-npc-skills.d2cap', + '05-d2gs-vendor-open.d2cap', + ]) { + const text = readFileSync(resolve('tests/fixtures/netproto', fixture), 'utf8') + const session = new OnlineSession({ settingsStore: createSettingsStore() }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + for (let i = 0; i < 30; i++) session.tick(40, 1000 + i * 40) + + const renderUnits = session.buildRenderableUnits() + const monsterUnits = renderUnits.filter(u => u.kind === 'monster') + expect(monsterUnits.length, `${fixture} should have camp NPCs`).toBeGreaterThan(0) + + for (const npc of monsterUnits) { + expect(npc.isCorpse, `${fixture} npc id=${npc.id} classId=${npc.classId}`).toBe(false) + expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(0) + expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(12) + } + + const scene = buildSceneDrawables(renderUnits) + const monsterDrawables = scene.unitDrawables.filter(u => u.unitKind === 'monster') + expect(monsterDrawables.length).toBe(monsterUnits.length) + for (const d of monsterDrawables) { + expect(d.isCorpse, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBe(false) + expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBeGreaterThan(0) + expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).not.toBe(12) + } + } + }) }) diff --git a/tests/netproto/d2gs-s2c.test.ts b/tests/netproto/d2gs-s2c.test.ts index ece8d34..0690eba 100644 --- a/tests/netproto/d2gs-s2c.test.ts +++ b/tests/netproto/d2gs-s2c.test.ts @@ -141,4 +141,116 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => { partnerId: 7, }) }) + + it('decodes 0xAC AssignNPC 4-bit bitstream mode (D2Game.dll 0x6fcacac9 / D2Client.dll 0x6fb5e462)', () => { + // Alive Rogue Scout (classId=564) with bitstream byte 0x01 (low 4 bits = 1 MONMODE_NEUTRAL) + const alivePkt = new Uint8Array([ + 0xac, + 0x2a, 0x00, 0x00, 0x00, // id = 42 + 0x34, 0x02, // classId = 564 (act1hire / Rogue Scout) + 0xf6, 0x13, // x = 5110 + 0xc4, 0x13, // y = 5060 + 0x80, // lifePct = 128 (full HP) + 0x0e, // packet length = 14 + 0x01, // bitstream: low 4 bits = 1 (MONMODE_NEUTRAL) + ]) + const aliveEv = decodeD2gsServerPacket(alivePkt) + expect(aliveEv.type).toBe('UnitAssign') + if (aliveEv.type === 'UnitAssign') { + expect(aliveEv.unit.unitType).toBe(1) + expect(aliveEv.unit.id).toBe(42) + expect(aliveEv.unit.classId).toBe(564) + expect(aliveEv.unit.lifePct).toBe(128) + expect(aliveEv.unit.mode).toBe(1) // MONMODE_NEUTRAL, never 0 (MONMODE_DEATH) + expect(aliveEv.unit.direction).toBeGreaterThanOrEqual(0) + expect(aliveEv.unit.direction).toBeLessThan(64) + } + + // Dead monster corpse (lifePct = 0, bitstream low 4 bits = 12 MONMODE_DEAD) + const corpsePkt = new Uint8Array([ + 0xac, + 0x99, 0x00, 0x00, 0x00, + 0x01, 0x00, + 0x00, 0x14, + 0x00, 0x14, + 0x00, // lifePct = 0 + 0x0e, + 0x0c, // low 4 bits = 12 (MONMODE_DEAD) + ]) + const corpseEv = decodeD2gsServerPacket(corpsePkt) + expect(corpseEv.type).toBe('UnitAssign') + if (corpseEv.type === 'UnitAssign') { + expect(corpseEv.unit.lifePct).toBe(0) + expect(corpseEv.unit.mode).toBe(12) + } + }) + + it('maps 0x69/0x6A/0x6B state command index via D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE)', () => { + // 0x69 SetNPCMode: [0x69, id:u32, cmd:u8, x:u16, y:u16, lifePct:u8, unk:u8] + const make69 = (cmd: number, lifePct: number) => { + const w = new ByteWriter(12) + w.u8(0x69) + w.u32LE(77) + w.u8(cmd) + w.u16LE(5100) + w.u16LE(5200) + w.u8(lifePct) + w.u8(0) + return decodeD2gsServerPacket(w.toUint8Array()) + } + + // cmd 0x07 -> mode 1 (MONMODE_NEUTRAL) + expect(make69(0x07, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 1, lifePct: 128 }) + // cmd 0x06 -> mode 3 (MONMODE_GETHIT) + expect(make69(0x06, 96)).toMatchObject({ type: 'UnitMode', id: 77, mode: 3, lifePct: 96 }) + // cmd 0x08 -> mode 0 (MONMODE_DEATH) + expect(make69(0x08, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 0, lifePct: 0 }) + // cmd 0x09 -> mode 12 (MONMODE_DEAD) + expect(make69(0x09, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 12, lifePct: 0 }) + // cmd 0x0A -> mode 4 (MONMODE_ATTACK1) + expect(make69(0x0a, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 4, lifePct: 128 }) + // cmd 0x10 -> mode 5 (MONMODE_ATTACK2) + expect(make69(0x10, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 5, lifePct: 128 }) + // cmd 0x04 -> mode 7 (MONMODE_CAST) + expect(make69(0x04, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 7, lifePct: 128 }) + + // Real 1.13c Charsi 0x6B NPCAction2 packet from 02-d2gs-town-walk-chat.d2cap: + // 6b 08000000 0c 000000000000 3f16 9a11 (rawCmd=0x0C Sequence, paramByte=0x00 must NOT zero out lifePct) + const charsi6b = new Uint8Array([ + 0x6b, + 0x08, 0x00, 0x00, 0x00, // id = 8 (Charsi) + 0x0c, // rawCmd = 0x0C -> mode 8 (MONMODE_SEQUENCE) + 0x00, 0x00, 0x00, 0x00, // targetX=0, targetY=0 + 0x00, 0x00, // paramByte=0, animSpeed=0 + 0x3f, 0x16, // x = 5695 + 0x9a, 0x11, // y = 4506 + ]) + const charsiEv = decodeD2gsServerPacket(charsi6b) + expect(charsiEv).toEqual({ + type: 'UnitMode', + unitType: 1, + id: 8, + mode: 8, + at: { x: 5695, y: 4506 }, + }) + }) + + it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => { + const w68Run = new ByteWriter(21) + w68Run.u8(0x68) + w68Run.u32LE(88) + w68Run.u8(0x18) // 0x18 = run in 0x68 NPCMoveToTarget + w68Run.u16LE(5100) + w68Run.u16LE(5200) + w68Run.u8(0) // targetType = player + w68Run.u32LE(1) // targetId = 1 + w68Run.bytes(new Uint8Array(6)) + const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array()) + expect(ev68).toMatchObject({ + type: 'UnitMove', + unitType: 1, + id: 88, + mode: 'run', + }) + }) })