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
This commit is contained in:
parent
8fcd2238cc
commit
59435527b0
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) ??
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue