Merge branch 'fix/issue-559-monster-nameplate' into main (Closes #559)
# Conflicts: # src/common/units/monsters-data.ts
This commit is contained in:
commit
394369e9c1
|
|
@ -350,6 +350,10 @@ async function main() {
|
||||||
for (const r of (await readTxt('SuperUniques.txt')).rows) {
|
for (const r of (await readTxt('SuperUniques.txt')).rows) {
|
||||||
addKey(r['Name'])
|
addKey(r['Name'])
|
||||||
}
|
}
|
||||||
|
for (const fn of ['UniquePrefix.txt', 'UniqueSuffix.txt', 'UniqueAppellation.txt']) {
|
||||||
|
const t = await readTxt(fn)
|
||||||
|
for (const r of t.rows) addKey(r['Name'])
|
||||||
|
}
|
||||||
for (const r of (await readTxt('Levels.txt')).rows) {
|
for (const r of (await readTxt('Levels.txt')).rows) {
|
||||||
addKey(r['LevelName'])
|
addKey(r['LevelName'])
|
||||||
addKey(r['LevelWarp'])
|
addKey(r['LevelWarp'])
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,16 @@
|
||||||
|
|
||||||
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
|
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
|
||||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||||
import { monsterDisplayName } from '../../common/units/monsters-data.ts'
|
import {
|
||||||
|
CANONICAL_ELITE_MODIFIERS,
|
||||||
|
FIXED_UNIQUE_BOSS_HCIDX_SET,
|
||||||
|
applyMonsterUModResistances,
|
||||||
|
getMonsterKindByHcIdx,
|
||||||
|
getSuperUniqueByIndex,
|
||||||
|
monsterDisplayName,
|
||||||
|
rollUniqueMonsterNameKeys,
|
||||||
|
type Difficulty,
|
||||||
|
} from '../../common/units/monsters-data.ts'
|
||||||
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
|
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
|
||||||
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
|
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
|
||||||
import { ClickButtonId } from '../../netproto/index.ts'
|
import { ClickButtonId } from '../../netproto/index.ts'
|
||||||
|
|
@ -30,7 +39,12 @@ import {
|
||||||
type GridPlacement,
|
type GridPlacement,
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
} from '../ui/inventory.ts'
|
} from '../ui/inventory.ts'
|
||||||
import { GAMBLE_NPC_CLASS_IDS, type MonsterTargetHudInfo } from '../ui/world-panels.ts'
|
import {
|
||||||
|
GAMBLE_NPC_CLASS_IDS,
|
||||||
|
MONSTER_UMOD_TBL_KEYS,
|
||||||
|
lookupMonsterTblString,
|
||||||
|
type MonsterTargetHudInfo,
|
||||||
|
} from '../ui/world-panels.ts'
|
||||||
import { requireTbl } from '../i18n/lang.ts'
|
import { requireTbl } from '../i18n/lang.ts'
|
||||||
import { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts'
|
import { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts'
|
||||||
import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts'
|
import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts'
|
||||||
|
|
@ -1231,11 +1245,17 @@ export class HudModel implements HudCommandSink {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const unit = this.world.getUnit('monster', this.hoveredMonsterId)
|
const unit = this.world.getUnit('monster', this.hoveredMonsterId)
|
||||||
if (!unit || unit.lifePct <= 0) {
|
if (!unit || unit.lifePct <= 0 || unit.mode === 0 || unit.mode === 12) {
|
||||||
hud.targetMonster = null
|
hud.targetMonster = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hud.targetMonster = clientUnitToMonsterTargetInfo(unit)
|
// D2Client.dll 0x6fb708a3..0x6fb708c4: skip town NPCs, non-killable units, and empty NameStr
|
||||||
|
const kind = getMonsterKindByHcIdx(unit.classId, this.world.difficulty)
|
||||||
|
if (kind && (kind.inTown || !kind.killable || !kind.nameKey)) {
|
||||||
|
hud.targetMonster = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hud.targetMonster = clientUnitToMonsterTargetInfo(unit, this.world.difficulty)
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitCommand(cmd: ClientCommand): void {
|
private emitCommand(cmd: ClientCommand): void {
|
||||||
|
|
@ -1943,25 +1963,118 @@ export class HudModel implements HudCommandSink {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clientUnitToMonsterTargetInfo(unit: ClientUnit): MonsterTargetHudInfo {
|
export function clientUnitToMonsterTargetInfo(
|
||||||
|
unit: ClientUnit,
|
||||||
|
difficulty: 0 | 1 | 2 | Difficulty = 0,
|
||||||
|
): MonsterTargetHudInfo {
|
||||||
const hpPct = Math.max(0, Math.min(128, unit.lifePct))
|
const hpPct = Math.max(0, Math.min(128, unit.lifePct))
|
||||||
const rank: MonsterTargetHudInfo['rank'] = unit.isSuperUnique
|
const kind = getMonsterKindByHcIdx(unit.classId, difficulty)
|
||||||
|
|
||||||
|
const isSuperUnique = Boolean(unit.isSuperUnique || (unit.flags & 0x02) !== 0)
|
||||||
|
const isUnique = Boolean(!isSuperUnique && (unit.isUnique || (unit.flags & 0x08) !== 0))
|
||||||
|
const isChampion = Boolean(
|
||||||
|
!isSuperUnique && !isUnique && (unit.isChampion || (unit.flags & 0x04) !== 0),
|
||||||
|
)
|
||||||
|
const isMinion = Boolean(
|
||||||
|
!isSuperUnique && !isUnique && !isChampion && (unit.flags & 0x10) !== 0,
|
||||||
|
)
|
||||||
|
const isBoss = Boolean(kind?.boss)
|
||||||
|
|
||||||
|
const rank: MonsterTargetHudInfo['rank'] = isSuperUnique
|
||||||
? 'superunique'
|
? 'superunique'
|
||||||
: unit.isUnique
|
: isUnique
|
||||||
? 'unique'
|
? 'unique'
|
||||||
: unit.isChampion
|
: isChampion
|
||||||
? 'champion'
|
? 'champion'
|
||||||
: 'normal'
|
: isMinion
|
||||||
const resolvedName =
|
? 'minion'
|
||||||
unit.name && unit.name.trim().length > 0
|
: isBoss
|
||||||
? unit.name
|
? 'boss'
|
||||||
: monsterDisplayName(`mon_${unit.classId}`, 'ENG') || `Monster #${unit.classId}`
|
: 'normal'
|
||||||
|
|
||||||
|
const su =
|
||||||
|
isSuperUnique && unit.superUniqueId !== undefined
|
||||||
|
? getSuperUniqueByIndex(unit.superUniqueId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const explicitName = unit.name && unit.name.trim().length > 0 ? unit.name.trim() : undefined
|
||||||
|
const nameKey = su?.nameKey ?? explicitName ?? kind?.nameKey
|
||||||
|
|
||||||
|
let resolvedName =
|
||||||
|
explicitName ??
|
||||||
|
(nameKey
|
||||||
|
? (lookupMonsterTblString(nameKey, 'en') ??
|
||||||
|
monsterDisplayName(nameKey, 'ENG') ??
|
||||||
|
nameKey)
|
||||||
|
: `Monster #${unit.classId}`)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!explicitName &&
|
||||||
|
isUnique &&
|
||||||
|
unit.nameSeed !== undefined &&
|
||||||
|
!FIXED_UNIQUE_BOSS_HCIDX_SET.has(unit.classId)
|
||||||
|
) {
|
||||||
|
if (unit.bossMods?.includes(12)) {
|
||||||
|
resolvedName = lookupMonsterTblString('Blood Raven', 'en') ?? 'Blood Raven'
|
||||||
|
} else {
|
||||||
|
const partKeys = rollUniqueMonsterNameKeys(unit.nameSeed)
|
||||||
|
resolvedName = partKeys.map(k => lookupMonsterTblString(k, 'en') ?? k).join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D2Client.dll 0x6facfedb & 0x6fb6fca0: Uber bosses (hcIdx 704..709) suppress MonUMod labels and immunities
|
||||||
|
const isUberBoss = unit.classId >= 704 && unit.classId <= 709
|
||||||
|
|
||||||
|
const rawModIds: number[] = isUberBoss
|
||||||
|
? []
|
||||||
|
: unit.bossMods && unit.bossMods.length > 0
|
||||||
|
? [...unit.bossMods]
|
||||||
|
: su?.modifiers && su.modifiers.length > 0
|
||||||
|
? [...su.modifiers]
|
||||||
|
: []
|
||||||
|
if (!isUberBoss && isChampion && (unit.flags & 0x40) !== 0 && !rawModIds.includes(36)) {
|
||||||
|
rawModIds.unshift(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers = rawModIds.map(
|
||||||
|
(m) => CANONICAL_ELITE_MODIFIERS.find((e) => e.id === m)?.name ?? String(m),
|
||||||
|
)
|
||||||
|
const affixes = rawModIds.map((m) => {
|
||||||
|
const key = String(m)
|
||||||
|
const entry =
|
||||||
|
MONSTER_UMOD_TBL_KEYS[key] ??
|
||||||
|
CANONICAL_ELITE_MODIFIERS.find((e) => e.id === m)
|
||||||
|
if (entry) {
|
||||||
|
return lookupMonsterTblString(entry.tblKey, 'en') ?? entry.label
|
||||||
|
}
|
||||||
|
return `Mod #${m}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const descKey =
|
||||||
|
!isUberBoss && kind?.descKey && kind.descKey.length > 0
|
||||||
|
? kind.descKey
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const resistances =
|
||||||
|
!isUberBoss && kind?.resistances
|
||||||
|
? applyMonsterUModResistances(kind.resistances, rawModIds)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
|
hcIdx: unit.classId,
|
||||||
|
...(unit.nameSeed !== undefined ? { nameSeed: unit.nameSeed } : {}),
|
||||||
|
...(nameKey !== undefined ? { nameKey } : {}),
|
||||||
|
...(descKey !== undefined ? { descKey } : {}),
|
||||||
hp: hpPct,
|
hp: hpPct,
|
||||||
maxHp: 128,
|
maxHp: 128,
|
||||||
rank,
|
rank,
|
||||||
affixes: unit.bossMods ? unit.bossMods.map((m) => `Mod #${m}`) : [],
|
...(kind?.isUndead ? { isUndead: true } : {}),
|
||||||
|
...(kind?.isDemon ? { isDemon: true } : {}),
|
||||||
|
...(isBoss ? { isBoss: true } : {}),
|
||||||
|
modifiers,
|
||||||
|
affixes,
|
||||||
|
...(resistances !== undefined ? { resistances } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,12 @@ import {
|
||||||
} from '../../common/units/npc-table.ts'
|
} from '../../common/units/npc-table.ts'
|
||||||
import type { VendorTabId } from '../../common/items/vendor-generator.ts'
|
import type { VendorTabId } from '../../common/items/vendor-generator.ts'
|
||||||
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../../common/data/canonical-tbl-strings.ts'
|
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../../common/data/canonical-tbl-strings.ts'
|
||||||
import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../../common/units/monsters-data.ts'
|
import {
|
||||||
|
CANONICAL_ELITE_MODIFIERS,
|
||||||
|
FIXED_UNIQUE_BOSS_HCIDX_SET,
|
||||||
|
monsterDisplayName,
|
||||||
|
rollUniqueMonsterNameKeys,
|
||||||
|
} from '../../common/units/monsters-data.ts'
|
||||||
import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts'
|
import { getSocketableInfo, isJewelCode } from '../../common/items/gems-data.ts'
|
||||||
import {
|
import {
|
||||||
transmuteCubeItems,
|
transmuteCubeItems,
|
||||||
|
|
@ -5085,6 +5090,8 @@ export function hitTestEscapeMenu(
|
||||||
export interface MonsterTargetHudInfo {
|
export interface MonsterTargetHudInfo {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly nameKey?: string | undefined
|
readonly nameKey?: string | undefined
|
||||||
|
readonly hcIdx?: number | undefined
|
||||||
|
readonly nameSeed?: number | undefined
|
||||||
readonly hp: number
|
readonly hp: number
|
||||||
readonly maxHp: number
|
readonly maxHp: number
|
||||||
readonly rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'minion' | 'boss' | undefined
|
readonly rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'minion' | 'boss' | undefined
|
||||||
|
|
@ -5171,7 +5178,8 @@ const CHAMPION_SUBTYPE_TBL_KEYS: Readonly<Record<string, string>> = {
|
||||||
/**
|
/**
|
||||||
* Resolve localized monster title, 2-space lifebar padding (`" " + Name + " "`),
|
* Resolve localized monster title, 2-space lifebar padding (`" " + Name + " "`),
|
||||||
* and canonical rank color (`white` Normal/Minion, `blue` Champion, `gold` Unique/SuperUnique/Boss)
|
* and canonical rank color (`white` Normal/Minion, `blue` Champion, `gold` Unique/SuperUnique/Boss)
|
||||||
* per `D2Client.dll` `sub_6FACFDD0` (`0x6FACFDD0`) and `sub_6FB70850` (`0x6FB70850`).
|
* per `D2Client.dll` `sub_6FACFDD0` (`0x6FACFDD0`), `sub_6FB70850` (`0x6FB70850`),
|
||||||
|
* and `sub_6FAD0620` (`0x6FAD0620`).
|
||||||
*/
|
*/
|
||||||
export function resolveMonsterTitleAndColor(
|
export function resolveMonsterTitleAndColor(
|
||||||
target: MonsterTargetHudInfo,
|
target: MonsterTargetHudInfo,
|
||||||
|
|
@ -5194,6 +5202,35 @@ export function resolveMonsterTitleAndColor(
|
||||||
Boolean(target.isBoss)
|
Boolean(target.isBoss)
|
||||||
|
|
||||||
if (isGoldRank) {
|
if (isGoldRank) {
|
||||||
|
if (
|
||||||
|
target.rank === 'unique' &&
|
||||||
|
target.nameSeed !== undefined &&
|
||||||
|
!FIXED_UNIQUE_BOSS_HCIDX_SET.has(target.hcIdx ?? -1)
|
||||||
|
) {
|
||||||
|
const mods =
|
||||||
|
target.modifiers && target.modifiers.length > 0
|
||||||
|
? target.modifiers
|
||||||
|
: (target.affixes ?? [])
|
||||||
|
const hasBloodRavenUMod = mods.some(m => String(m).trim() === '12')
|
||||||
|
if (hasBloodRavenUMod) {
|
||||||
|
const brName = lookupMonsterTblString('Blood Raven', locale) ?? 'Blood Raven'
|
||||||
|
return {
|
||||||
|
displayName: brName,
|
||||||
|
paddedTitle: ` ${brName} `,
|
||||||
|
titleColor: 'gold',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const partKeys = rollUniqueMonsterNameKeys(target.nameSeed)
|
||||||
|
const rolledName = partKeys
|
||||||
|
.map(k => lookupMonsterTblString(k, locale) ?? k)
|
||||||
|
.join(' ')
|
||||||
|
return {
|
||||||
|
displayName: rolledName,
|
||||||
|
paddedTitle: ` ${rolledName} `,
|
||||||
|
titleColor: 'gold',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName: baseName,
|
displayName: baseName,
|
||||||
paddedTitle: ` ${baseName} `,
|
paddedTitle: ` ${baseName} `,
|
||||||
|
|
@ -5330,7 +5367,7 @@ export const CANONICAL_IMMUNITY_DESCRIPTORS: readonly {
|
||||||
* covering all 14 displayed `MonUMod.txt` IDs (`5..9, 17, 18, 24..30`),
|
* covering all 14 displayed `MonUMod.txt` IDs (`5..9, 17, 18, 24..30`),
|
||||||
* canonical names, `.tbl` keys (`3205..3218`), and `MonUMod.txt` short names.
|
* canonical names, `.tbl` keys (`3205..3218`), and `MonUMod.txt` short names.
|
||||||
*/
|
*/
|
||||||
const MONSTER_UMOD_TBL_KEYS: Readonly<Record<string, { readonly tblKey: string; readonly label: string }>> = {
|
export const MONSTER_UMOD_TBL_KEYS: Readonly<Record<string, { readonly tblKey: string; readonly label: string }>> = {
|
||||||
'5': { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
|
'5': { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
|
||||||
strong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
|
strong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
|
||||||
uniquextrastrong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
|
uniquextrastrong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ export class ClientUnit {
|
||||||
isSuperUnique?: boolean | undefined
|
isSuperUnique?: boolean | undefined
|
||||||
superUniqueId?: number | undefined
|
superUniqueId?: number | undefined
|
||||||
bossMods?: readonly number[] | undefined
|
bossMods?: readonly number[] | undefined
|
||||||
|
nameSeed?: number | undefined
|
||||||
|
compVariants?: readonly number[] | undefined
|
||||||
ownerType?: UnitTypeName | undefined
|
ownerType?: UnitTypeName | undefined
|
||||||
ownerId?: number | undefined
|
ownerId?: number | undefined
|
||||||
overheadText?: string | undefined
|
overheadText?: string | undefined
|
||||||
|
|
@ -150,11 +152,13 @@ export class ClientUnit {
|
||||||
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
|
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
|
||||||
this.objectState = snapshot.objectState
|
this.objectState = snapshot.objectState
|
||||||
this.interactType = snapshot.interactType
|
this.interactType = snapshot.interactType
|
||||||
this.isChampion = snapshot.isChampion
|
this.isChampion = snapshot.isChampion ?? ((this.flags & 0x04) !== 0 ? true : undefined)
|
||||||
this.isUnique = snapshot.isUnique
|
this.isUnique = snapshot.isUnique ?? ((this.flags & 0x08) !== 0 ? true : undefined)
|
||||||
this.isSuperUnique = snapshot.isSuperUnique
|
this.isSuperUnique = snapshot.isSuperUnique ?? ((this.flags & 0x02) !== 0 ? true : undefined)
|
||||||
this.superUniqueId = snapshot.superUniqueId
|
this.superUniqueId = snapshot.superUniqueId
|
||||||
this.bossMods = snapshot.bossMods
|
this.bossMods = snapshot.bossMods
|
||||||
|
this.nameSeed = snapshot.nameSeed
|
||||||
|
this.compVariants = snapshot.compVariants
|
||||||
this.ownerType = snapshot.ownerType !== undefined ? unitTypeFromId(snapshot.ownerType) : undefined
|
this.ownerType = snapshot.ownerType !== undefined ? unitTypeFromId(snapshot.ownerType) : undefined
|
||||||
this.ownerId = snapshot.ownerId
|
this.ownerId = snapshot.ownerId
|
||||||
if (snapshot.states) {
|
if (snapshot.states) {
|
||||||
|
|
@ -211,6 +215,9 @@ export class ClientUnit {
|
||||||
}
|
}
|
||||||
if (snapshot.flags !== undefined) {
|
if (snapshot.flags !== undefined) {
|
||||||
this.flags = snapshot.flags
|
this.flags = snapshot.flags
|
||||||
|
if ((snapshot.flags & 0x04) !== 0) this.isChampion = true
|
||||||
|
if ((snapshot.flags & 0x08) !== 0) this.isUnique = true
|
||||||
|
if ((snapshot.flags & 0x02) !== 0) this.isSuperUnique = true
|
||||||
}
|
}
|
||||||
if (snapshot.warpId !== undefined) {
|
if (snapshot.warpId !== undefined) {
|
||||||
this.warpId = snapshot.warpId
|
this.warpId = snapshot.warpId
|
||||||
|
|
@ -238,6 +245,12 @@ export class ClientUnit {
|
||||||
if (snapshot.bossMods !== undefined) {
|
if (snapshot.bossMods !== undefined) {
|
||||||
this.bossMods = snapshot.bossMods
|
this.bossMods = snapshot.bossMods
|
||||||
}
|
}
|
||||||
|
if (snapshot.nameSeed !== undefined) {
|
||||||
|
this.nameSeed = snapshot.nameSeed
|
||||||
|
}
|
||||||
|
if (snapshot.compVariants !== undefined) {
|
||||||
|
this.compVariants = snapshot.compVariants
|
||||||
|
}
|
||||||
if (snapshot.ownerType !== undefined) {
|
if (snapshot.ownerType !== undefined) {
|
||||||
this.ownerType = unitTypeFromId(snapshot.ownerType)
|
this.ownerType = unitTypeFromId(snapshot.ownerType)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2135,6 +2135,232 @@ export function getMonsterKindByClassId(classId: number): MonsterKind | undefine
|
||||||
return getCanonicalMonsterKinds().get(id)
|
return getCanonicalMonsterKinds().get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedMonsterKindsByHcIdx = new Map<Difficulty, readonly MonsterKind[]>()
|
||||||
|
let cachedSuperUniquesByIndex: readonly SuperUnique[] | null = null
|
||||||
|
|
||||||
|
function normalizeDifficulty(difficulty: Difficulty | number = 'normal'): Difficulty {
|
||||||
|
if (typeof difficulty === 'number') {
|
||||||
|
return difficulty === 2 ? 'hell' : difficulty === 1 ? 'nightmare' : 'normal'
|
||||||
|
}
|
||||||
|
return difficulty
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a canonical 1.13c `MonsterKind` by its 0-based `MonStats.txt` / `monstats.bin` `hcIdx` (`0..733`)
|
||||||
|
* and difficulty (`'normal' | 'nightmare' | 'hell'` or `0 | 1 | 2`).
|
||||||
|
*/
|
||||||
|
export function getMonsterKindByHcIdx(
|
||||||
|
hcIdx: number,
|
||||||
|
difficulty: Difficulty | number = 'normal',
|
||||||
|
): MonsterKind | undefined {
|
||||||
|
if (!Number.isInteger(hcIdx) || hcIdx < 0) return undefined
|
||||||
|
const diff = normalizeDifficulty(difficulty)
|
||||||
|
let list = cachedMonsterKindsByHcIdx.get(diff)
|
||||||
|
if (!list) {
|
||||||
|
const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), diff)
|
||||||
|
const arr: MonsterKind[] = []
|
||||||
|
for (const [id, kind] of rawKinds) {
|
||||||
|
const ov = diff === 'normal' ? CANONICAL_113C_MONSTER_OVERRIDES[id] : undefined
|
||||||
|
const enrichedKind: MonsterKind = {
|
||||||
|
...kind,
|
||||||
|
enabled: kind.killable || !kind.inTown,
|
||||||
|
velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5),
|
||||||
|
runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5),
|
||||||
|
minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21),
|
||||||
|
maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61),
|
||||||
|
armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84),
|
||||||
|
experience: ov?.experience ?? (kind.experience > 0 ? kind.experience : 61),
|
||||||
|
attack1: ov?.attack1 ?? (kind.attack1.maxDamage > 0 ? kind.attack1 : { minDamage: 51, maxDamage: 101, toHit: 101 }),
|
||||||
|
attack2: ov?.attack2 ?? kind.attack2,
|
||||||
|
}
|
||||||
|
if (typeof (kind as any).getTreasureClass === 'function') {
|
||||||
|
Object.defineProperty(enrichedKind, 'getTreasureClass', {
|
||||||
|
value: (kind as any).getTreasureClass,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
arr.push(enrichedKind)
|
||||||
|
}
|
||||||
|
list = arr
|
||||||
|
cachedMonsterKindsByHcIdx.set(diff, list)
|
||||||
|
}
|
||||||
|
return list[hcIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a canonical 1.13c `SuperUnique` by its 0-based `SuperUniques.txt` / `superuniques.bin` `wIdx` (`0..65`).
|
||||||
|
*/
|
||||||
|
export function getSuperUniqueByIndex(index: number): SuperUnique | undefined {
|
||||||
|
if (!Number.isInteger(index) || index < 0) return undefined
|
||||||
|
if (cachedSuperUniquesByIndex === null) {
|
||||||
|
cachedSuperUniquesByIndex = readSuperUniques(
|
||||||
|
parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES.trim())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cachedSuperUniquesByIndex[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply 1.13c `MonUMod` resistance bonuses (`D2Game.dll` `0x6fccf610`) to a monster's base difficulty resistances.
|
||||||
|
*/
|
||||||
|
export function applyMonsterUModResistances(
|
||||||
|
base: MonsterResistances,
|
||||||
|
bossMods: readonly (number | string)[] | undefined,
|
||||||
|
): MonsterResistances {
|
||||||
|
if (!bossMods || bossMods.length === 0) return base
|
||||||
|
|
||||||
|
let physical = base.physical
|
||||||
|
let magic = base.magic
|
||||||
|
let fire = base.fire
|
||||||
|
let lightning = base.lightning
|
||||||
|
let cold = base.cold
|
||||||
|
const poison = base.poison
|
||||||
|
|
||||||
|
const countImmunities = () =>
|
||||||
|
(physical >= 100 ? 1 : 0) +
|
||||||
|
(magic >= 100 ? 1 : 0) +
|
||||||
|
(fire >= 100 ? 1 : 0) +
|
||||||
|
(lightning >= 100 ? 1 : 0) +
|
||||||
|
(cold >= 100 ? 1 : 0) +
|
||||||
|
(poison >= 100 ? 1 : 0)
|
||||||
|
|
||||||
|
for (const rawMod of bossMods) {
|
||||||
|
const key = String(rawMod).trim().toLowerCase()
|
||||||
|
if (key === '8' || key === 'magicresistant') {
|
||||||
|
if (countImmunities() < 2) fire += 40
|
||||||
|
if (countImmunities() < 2) lightning += 40
|
||||||
|
if (countImmunities() < 2) cold += 40
|
||||||
|
} else if (key === '9' || key === 'fireenchant') {
|
||||||
|
if (countImmunities() < 2 || fire >= 100) fire += 75
|
||||||
|
} else if (key === '17' || key === 'lightenchant') {
|
||||||
|
if (countImmunities() < 2 || lightning >= 100) lightning += 75
|
||||||
|
} else if (key === '18' || key === 'coldenchant') {
|
||||||
|
if (countImmunities() < 2 || cold >= 100) cold += 75
|
||||||
|
} else if (key === '25' || key === 'manahit') {
|
||||||
|
magic += 20
|
||||||
|
} else if (key === '27' || key === 'spectralhit') {
|
||||||
|
if (countImmunities() < 2 && fire < 75) fire += 20
|
||||||
|
if (countImmunities() < 2 && lightning < 75) lightning += 20
|
||||||
|
if (countImmunities() < 2 && cold < 75) cold += 20
|
||||||
|
} else if (key === '28' || key === 'stoneskin') {
|
||||||
|
physical += 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
physical,
|
||||||
|
magic,
|
||||||
|
fire,
|
||||||
|
lightning,
|
||||||
|
cold,
|
||||||
|
poison,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical 1.13c `UniquePrefix.txt` / `uniqueprefix.bin` `.tbl` keys (53 records).
|
||||||
|
* Ground truth: `D2Common.dll` `0x6fdaa810` (`MONSTER_ChooseRandomUniquePrefix`, ordinal `10249`).
|
||||||
|
*/
|
||||||
|
export const UNIQUE_MONSTER_PREFIX_KEYS = [
|
||||||
|
'GloomUM', 'Gray', 'DireUM', 'Black', 'ShadowUM', 'Haze', 'Wind', 'StormUM', 'Warp', 'Night',
|
||||||
|
'Moon', 'Star', 'Pit', 'Fire', 'Cold', 'Seethe', 'SharpUM', 'AshUM', 'Blade', 'SteelUM',
|
||||||
|
'StoneUM', 'Rust', 'Mold', 'Blight', 'Plague', 'Rot', 'Ooze', 'Puke', 'Snot', 'Bile',
|
||||||
|
'BloodUM', 'Pulse', 'Gut', 'Gore', 'FleshUM', 'BoneUM', 'SpineUM', 'Mind', 'SpiritUM', 'SoulUM',
|
||||||
|
'Wrath', 'GriefUM', 'Foul', 'Vile', 'Sin', 'ChaosUM', 'DreadUM', 'DoomUM', 'BaneUM', 'DeathUM',
|
||||||
|
'ViperUM', 'Dragon', 'Devil',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical 1.13c `UniqueSuffix.txt` / `uniquesuffix.bin` `.tbl` keys (69 records).
|
||||||
|
* Ground truth: `D2Common.dll` `0x6fdaa7d0` (`MONSTER_ChooseRandomUniqueSuffix`, ordinal `10852`).
|
||||||
|
*/
|
||||||
|
export const UNIQUE_MONSTER_SUFFIX_KEYS = [
|
||||||
|
'touchUM', 'spellUM', 'feast', 'wound', 'grin', 'maim', 'hack', 'biteUM', 'rendUM', 'burn',
|
||||||
|
'rip', 'kill', 'callUM', 'vex', 'jade', 'web', 'shieldUM', 'KillerUM', 'RazorUM', 'drinker',
|
||||||
|
'shifter', 'crawler', 'dancer', 'bender', 'weaverUM', 'eater', 'widow', 'maggot', 'spawn', 'wight',
|
||||||
|
'GrumbleUM', 'GrowlerUM', 'SnarlUM', 'wolf', 'crow', 'hawk', 'cloud', 'BangUM', 'head', 'skullUM',
|
||||||
|
'browUM', 'eyeUM', 'maw', 'tongue', 'fangUM', 'hornUM', 'thorn', 'clawUM', 'fistUM', 'heartUM',
|
||||||
|
'shankUM', 'skinUM', 'wingUM', 'pox', 'fester', 'blister', 'pus', 'SlimeUM', 'drool', 'froth',
|
||||||
|
'sludge', 'venom', 'poison', 'break', 'shard', 'flame', 'maul', 'thirstUM', 'lust',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical 1.13c `UniqueAppellation.txt` / `uniqueappellation.bin` `.tbl` keys (25 records).
|
||||||
|
* Ground truth: `D2Common.dll` `0x6fdaa790` (`MONSTER_ChooseRandomUniqueAppellation`, ordinal `10272`).
|
||||||
|
*/
|
||||||
|
export const UNIQUE_MONSTER_APPELLATION_KEYS = [
|
||||||
|
'the Hammer', 'the Axe', 'the Sharp', 'the Jagged', 'the Flayer',
|
||||||
|
'the Slasher', 'the Impaler', 'the Hunter', 'the Slayer', 'the Mauler',
|
||||||
|
'the Destroyer', 'theQuick', 'the Witch', 'the Mad', 'the Wraith',
|
||||||
|
'the Shade', 'the Dead', 'the Unholy', 'the Howler', 'the Grim',
|
||||||
|
'the Dark', 'the Tainted', 'the Unclean', 'the Hungry', 'the Cold',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `MonStats.txt` `hcIdx` values that bypass random Unique name rolling in `D2Client.dll` `0x6fad07cc..0x6fad08ec`
|
||||||
|
* (`156` Andariel, `211` Duriel, `242` Mephisto, `243` Diablo, `256` Izual, `333` Diablo Clone, `544` Baal, `704..709` Uber Bosses).
|
||||||
|
*/
|
||||||
|
export const FIXED_UNIQUE_BOSS_HCIDX_SET: ReadonlySet<number> = new Set([
|
||||||
|
156, 211, 242, 243, 256, 333, 544, 704, 705, 706, 707, 708, 709,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic 1.13c random Unique monster name `.tbl` key sequence from `nameSeed`.
|
||||||
|
*
|
||||||
|
* Ground truth:
|
||||||
|
* - `D2Client.dll` `0x6fb5e5cb..0x6fb5e5d8`: stores `wNameSeed = nameSeed & 0xff`.
|
||||||
|
* - `D2Common.dll` `0x6fd86740` (`SEED_InitLowSeed`, ordinal `10614`): `low = wNameSeed`, `high = 0x29a` (`666`).
|
||||||
|
* - `D2Common.dll` `0x6fd51100` / `D2Client.dll` `0x6fab1330`: 64-bit MWC RNG `prod = 0x6ac690c5ULL * low + high`.
|
||||||
|
* - `D2Client.dll` `0x6fad08ef..0x6fad095c`:
|
||||||
|
* 1. `suffix1 = ChooseSuffix(pUnit)` (`roll(69)`, pushed first in `cdecl`)
|
||||||
|
* 2. `prefix1 = ChoosePrefix(pUnit)` (`roll(53)`, pushed second in `cdecl`)
|
||||||
|
* 3. `chance = SEED_RollLimitedRandomNumber(&pUnit->pSeed, 100)` (`roll(100)`)
|
||||||
|
* 4. If `chance < 50`:
|
||||||
|
* - `appellation = ChooseAppellation(pUnit)` (`roll(25)`, pushed first)
|
||||||
|
* - `suffix2 = ChooseSuffix(pUnit)` (`roll(69)`, pushed second)
|
||||||
|
* - `prefix2 = ChoosePrefix(pUnit)` (`roll(53)`, pushed third)
|
||||||
|
* - Formats `string.tbl#1722` (`"%0 %1 %2"`): `[prefix2, suffix2, appellation]`.
|
||||||
|
* 5. Else (`chance >= 50`):
|
||||||
|
* - Formats `string.tbl#1721` (`"%0 %1"`): `[prefix1, suffix1]`.
|
||||||
|
*/
|
||||||
|
export function rollUniqueMonsterNameKeys(nameSeed: number): readonly string[] {
|
||||||
|
let low = BigInt(nameSeed & 0xff)
|
||||||
|
let high = 0x29an
|
||||||
|
|
||||||
|
const roll = (range: number): number => {
|
||||||
|
if (range <= 0) return 0
|
||||||
|
const prod = 0x6ac690c5n * low + high
|
||||||
|
low = prod & 0xffffffffn
|
||||||
|
high = (prod >> 32n) & 0xffffffffn
|
||||||
|
if ((range & (range - 1)) === 0) {
|
||||||
|
return Number(low) & (range - 1)
|
||||||
|
}
|
||||||
|
return Number(low % BigInt(range))
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixIdx1 = roll(UNIQUE_MONSTER_SUFFIX_KEYS.length)
|
||||||
|
const prefixIdx1 = roll(UNIQUE_MONSTER_PREFIX_KEYS.length)
|
||||||
|
const chance = roll(100)
|
||||||
|
|
||||||
|
if (chance < 50) {
|
||||||
|
const appellationIdx = roll(UNIQUE_MONSTER_APPELLATION_KEYS.length)
|
||||||
|
const suffixIdx2 = roll(UNIQUE_MONSTER_SUFFIX_KEYS.length)
|
||||||
|
const prefixIdx2 = roll(UNIQUE_MONSTER_PREFIX_KEYS.length)
|
||||||
|
return [
|
||||||
|
UNIQUE_MONSTER_PREFIX_KEYS[prefixIdx2]!,
|
||||||
|
UNIQUE_MONSTER_SUFFIX_KEYS[suffixIdx2]!,
|
||||||
|
UNIQUE_MONSTER_APPELLATION_KEYS[appellationIdx]!,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
UNIQUE_MONSTER_PREFIX_KEYS[prefixIdx1]!,
|
||||||
|
UNIQUE_MONSTER_SUFFIX_KEYS[suffixIdx1]!,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute D2 1.13c monster movement speed in sub-tiles per second (`D2Common.dll`
|
* Compute D2 1.13c monster movement speed in sub-tiles per second (`D2Common.dll`
|
||||||
* `UNITS_GetBaseVelocity` 0x6FDB1170, `UNITS_GetVelocity` 0x6FDB1B60, and `PathMisc.cpp`
|
* `UNITS_GetBaseVelocity` 0x6FDB1170, `UNITS_GetVelocity` 0x6FDB1B60, and `PathMisc.cpp`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* Auto-generated from Diablo II v1.13c `monstats.bin` + `monstats2.bin`.
|
||||||
|
*
|
||||||
|
* Ground truth: `D2Client.dll` `0x6fb5e488..0x6fb5e4e3` and `0x6fab1260` (`D2Game.dll` `0x6fcacb35..0x6fcacb79`).
|
||||||
|
* When `0xAC` (`ASSIGN_NPC`) has `hasAnimComp === 1`, the bitstream encodes 16 component variant indices
|
||||||
|
* (`HD, TR, LG, RA, LA, RH, LH, SH, S1..S8`).
|
||||||
|
* For each component `i` (`0..15`), let `count = monstats2.bin[monstats.bin[hcIdx].wMonStatsEx].nComponentVariants[i]`:
|
||||||
|
* - If `count < 3` (`0`, `1`, or `2`), the bit width read from the `0xAC` stream is `1`.
|
||||||
|
* - If `count >= 3`, the bit width is `bsr(count - 1) + 1` (`32 - Math.clz32(count - 1)`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_MONSTER_COMP_BIT_WIDTHS: readonly number[] = [
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sparse map of `hcIdx` -> 16-element component bit-width array for monsters where at least one
|
||||||
|
* component has `nComponentVariants[i] >= 3` (`bitWidth > 1`).
|
||||||
|
* All other `hcIdx` in `0..733` use `DEFAULT_MONSTER_COMP_BIT_WIDTHS` (16 bits total, 1 bit each).
|
||||||
|
*/
|
||||||
|
export const MONSTATS2_COMP_BIT_WIDTHS_BY_HCIDX: Readonly<Record<number, readonly number[]>> = {
|
||||||
|
0: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
1: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
2: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
3: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
4: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
5: [2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
6: [2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
7: [2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
8: [2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
9: [2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
19: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
20: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
21: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
22: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
23: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
43: [2, 2, 2, 2, 2, 4, 1, 2, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
44: [2, 2, 2, 2, 2, 4, 1, 2, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
45: [2, 2, 2, 2, 2, 4, 1, 2, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
46: [2, 2, 2, 2, 2, 4, 1, 2, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
47: [2, 2, 2, 2, 2, 4, 1, 2, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
53: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
54: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
55: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
56: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
57: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
83: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
84: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
85: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
86: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
91: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
92: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
93: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
94: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
95: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
127: [3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
128: [3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
129: [3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
130: [3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
160: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
161: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
162: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
163: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
164: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
165: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
166: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
167: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
168: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
169: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
170: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
171: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
172: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
173: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
174: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
195: [2, 2, 2, 1, 1, 1, 1, 1, 4, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
196: [2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
235: [3, 3, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
236: [3, 3, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
237: [3, 3, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
274: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
275: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
276: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
277: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
294: [2, 2, 2, 2, 2, 2, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
296: [2, 2, 2, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
310: [2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
311: [2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 1, 1],
|
||||||
|
312: [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1],
|
||||||
|
360: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
361: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
362: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
363: [2, 1, 1, 2, 2, 4, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
364: [2, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1],
|
||||||
|
373: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
374: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
375: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
376: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
379: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
380: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
381: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
382: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
383: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
384: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
385: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
386: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
387: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
388: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
389: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
390: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
391: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
394: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
395: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
453: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
454: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
455: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
456: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
457: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
458: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
459: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
460: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
522: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
523: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
535: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
536: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
558: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
560: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
561: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
575: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
576: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
577: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
578: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
579: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
585: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
586: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
587: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
588: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
589: [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
609: [3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
610: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
611: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
612: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
613: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
614: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
615: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
616: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
617: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
620: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
621: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
622: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
623: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
624: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
625: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
626: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
627: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
628: [3, 2, 1, 2, 2, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1, 1],
|
||||||
|
642: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
643: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
644: [1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
648: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
649: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
654: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
655: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
671: [3, 3, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
672: [3, 3, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
682: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
683: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
684: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
686: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
687: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
699: [2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
700: [2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
701: [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1],
|
||||||
|
702: [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1],
|
||||||
|
712: [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
713: [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1],
|
||||||
|
718: [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
721: [2, 2, 2, 2, 2, 4, 1, 2, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
722: [2, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
723: [2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1],
|
||||||
|
725: [3, 2, 2, 2, 2, 4, 1, 3, 4, 4, 1, 1, 1, 1, 1, 1],
|
||||||
|
726: [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
727: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
728: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
729: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
730: [3, 2, 2, 2, 2, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONSTATS_HCIDX_COUNT = 734
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the 16 component bit widths read by `0xAC` (`ASSIGN_NPC`) when `hasAnimComp === 1`.
|
||||||
|
*/
|
||||||
|
export function getMonsterAnimCompBitWidths(hcIdx: number): readonly number[] {
|
||||||
|
return MONSTATS2_COMP_BIT_WIDTHS_BY_HCIDX[hcIdx] ?? DEFAULT_MONSTER_COMP_BIT_WIDTHS
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { BitReader } from '../../core/bit-reader.ts'
|
||||||
import { ByteReader } from '../../core/byte-reader.ts'
|
import { ByteReader } from '../../core/byte-reader.ts'
|
||||||
import type { UnitType } from '../../domain/ids.ts'
|
import type { UnitType } from '../../domain/ids.ts'
|
||||||
import type { ServerEvent } from '../../domain/server-event.ts'
|
import type { ServerEvent } from '../../domain/server-event.ts'
|
||||||
|
import { getMonsterAnimCompBitWidths } from './monstats2-comp-bits.ts'
|
||||||
|
|
||||||
function clampUnitType(raw: number): UnitType {
|
function clampUnitType(raw: number): UnitType {
|
||||||
return (raw >= 0 && raw <= 5 ? raw : 0) as UnitType
|
return (raw >= 0 && raw <= 5 ? raw : 0) as UnitType
|
||||||
|
|
@ -293,7 +294,15 @@ export function decode0x76UnitInSight(pkt: Uint8Array): ServerEvent {
|
||||||
* - 4 bits: initial monster mode `nMode` (`D2Game.dll 0x6fcacac9`: `0=DT`, `8=SQ`, `9=KB`, `12=DD`,
|
* - 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`)
|
* or `1=NU` for all other alive modes; applied via `D2Common_11090` at `0x6fb5787c`/`0x6fafd345`)
|
||||||
* - 1 bit: `hasAnimComp` (`0x6fb5e47f`)
|
* - 1 bit: `hasAnimComp` (`0x6fb5e47f`)
|
||||||
* - 1 bit: `hasMonsterFlags` (`0x6fb5e4fc`, followed by 5 boss flag bits when `hasAnimComp === 0`)
|
* - If `hasAnimComp === 1`, 16 component variant values follow with bit widths from
|
||||||
|
* `monstats2.bin[monstats.bin[hcIdx].wMonStatsEx].nComponentVariants[0..15]` (`0x6fab1260`).
|
||||||
|
* - 1 bit: `hasMonsterFlags` (`0x6fb5e4fc`)
|
||||||
|
* - If `hasMonsterFlags === 1`:
|
||||||
|
* - 5 flag bits: `champion (0x04)`, `unique (0x08)`, `superunique (0x02)`, `minion (0x10)`, `ghostly (0x40)`
|
||||||
|
* - If `superunique`: 16-bit `superUniqueId` (`0x6fb5e549`)
|
||||||
|
* - Up to 9 `uint8` `bossMods` terminated by `0` (`0x6fb5e565..0x6fb5e585`)
|
||||||
|
* - 16-bit `nameSeed` (`0x6fb5e594`)
|
||||||
|
* - 1 bit: `hasOwner` (`0x6fb5e5ae`), followed by 32-bit `ownerId` (`0x6fb5e5c4`) when `1`.
|
||||||
*/
|
*/
|
||||||
export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
|
|
@ -307,22 +316,93 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
||||||
|
|
||||||
let rawMode: number | undefined
|
let rawMode: number | undefined
|
||||||
let flags = 0
|
let flags = 0
|
||||||
|
let isChampion: boolean | undefined
|
||||||
|
let isUnique: boolean | undefined
|
||||||
|
let isSuperUnique: boolean | undefined
|
||||||
|
let superUniqueId: number | undefined
|
||||||
|
let bossMods: number[] | undefined
|
||||||
|
let nameSeed: number | undefined
|
||||||
|
let compVariants: number[] | undefined
|
||||||
|
let ownerId: number | undefined
|
||||||
|
|
||||||
if (packetLen > 13 && pkt.byteLength >= 13) {
|
if (packetLen > 13 && pkt.byteLength >= 13) {
|
||||||
const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen)))
|
const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen)))
|
||||||
if (bitReader.remainingBits >= 4) {
|
if (bitReader.remainingBits >= 4) {
|
||||||
rawMode = bitReader.readBits(4) // 4-bit initial monster mode (D2Client.dll 0x6fb5e462)
|
rawMode = bitReader.readBits(4) // 4-bit initial monster mode (D2Client.dll 0x6fb5e462)
|
||||||
if (bitReader.remainingBits >= 1) {
|
if (bitReader.remainingBits >= 1) {
|
||||||
const hasAnimComp = bitReader.readBit()
|
const hasAnimComp = bitReader.readBit()
|
||||||
// Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2).
|
let canReadFlags = true
|
||||||
// If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc:
|
if (hasAnimComp === 1) {
|
||||||
if (hasAnimComp === 0 && bitReader.remainingBits >= 1) {
|
const widths = getMonsterAnimCompBitWidths(classId)
|
||||||
|
const comps: number[] = []
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const w = widths[i] ?? 1
|
||||||
|
if (bitReader.remainingBits < w) {
|
||||||
|
canReadFlags = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
comps.push(bitReader.readBits(w))
|
||||||
|
}
|
||||||
|
if (canReadFlags) {
|
||||||
|
compVariants = comps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canReadFlags && bitReader.remainingBits >= 1) {
|
||||||
const hasMonsterFlags = bitReader.readBit()
|
const hasMonsterFlags = bitReader.readBit()
|
||||||
|
let canReadOwner = hasMonsterFlags === 0
|
||||||
if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) {
|
if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) {
|
||||||
if (bitReader.readBit()) flags |= 0x04 // champion
|
if (bitReader.readBit()) {
|
||||||
if (bitReader.readBit()) flags |= 0x08 // unique
|
flags |= 0x04 // champion
|
||||||
if (bitReader.readBit()) flags |= 0x02 // superunique
|
isChampion = true
|
||||||
|
}
|
||||||
|
if (bitReader.readBit()) {
|
||||||
|
flags |= 0x08 // unique
|
||||||
|
isUnique = true
|
||||||
|
}
|
||||||
|
if (bitReader.readBit()) {
|
||||||
|
flags |= 0x02 // superunique
|
||||||
|
isSuperUnique = true
|
||||||
|
}
|
||||||
if (bitReader.readBit()) flags |= 0x10 // minion
|
if (bitReader.readBit()) flags |= 0x10 // minion
|
||||||
if (bitReader.readBit()) flags |= 0x40 // ghostly
|
if (bitReader.readBit()) flags |= 0x40 // ghostly
|
||||||
|
|
||||||
|
let flagsComplete = true
|
||||||
|
if (isSuperUnique) {
|
||||||
|
if (bitReader.remainingBits >= 16) {
|
||||||
|
superUniqueId = bitReader.readBits(16)
|
||||||
|
} else {
|
||||||
|
flagsComplete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flagsComplete) {
|
||||||
|
const mods: number[] = []
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
if (bitReader.remainingBits < 8) {
|
||||||
|
flagsComplete = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const mod = bitReader.readBits(8)
|
||||||
|
if (mod === 0) break
|
||||||
|
mods.push(mod)
|
||||||
|
}
|
||||||
|
if (mods.length > 0) {
|
||||||
|
bossMods = mods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flagsComplete && bitReader.remainingBits >= 16) {
|
||||||
|
nameSeed = bitReader.readBits(16)
|
||||||
|
canReadOwner = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canReadOwner && bitReader.remainingBits >= 1) {
|
||||||
|
const hasOwner = bitReader.readBit()
|
||||||
|
if (hasOwner === 1 && bitReader.remainingBits >= 32) {
|
||||||
|
ownerId = bitReader.readBits(32) >>> 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -353,6 +433,14 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
||||||
direction,
|
direction,
|
||||||
lifePct,
|
lifePct,
|
||||||
flags,
|
flags,
|
||||||
|
...(isChampion !== undefined ? { isChampion } : {}),
|
||||||
|
...(isUnique !== undefined ? { isUnique } : {}),
|
||||||
|
...(isSuperUnique !== undefined ? { isSuperUnique } : {}),
|
||||||
|
...(superUniqueId !== undefined ? { superUniqueId } : {}),
|
||||||
|
...(bossMods !== undefined ? { bossMods } : {}),
|
||||||
|
...(nameSeed !== undefined ? { nameSeed } : {}),
|
||||||
|
...(compVariants !== undefined ? { compVariants } : {}),
|
||||||
|
...(ownerId !== undefined ? { ownerId } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,8 @@ export interface UnitSnapshot {
|
||||||
readonly isSuperUnique?: boolean | undefined
|
readonly isSuperUnique?: boolean | undefined
|
||||||
readonly superUniqueId?: number | undefined
|
readonly superUniqueId?: number | undefined
|
||||||
readonly bossMods?: readonly number[] | undefined
|
readonly bossMods?: readonly number[] | undefined
|
||||||
|
readonly nameSeed?: number | undefined
|
||||||
|
readonly compVariants?: readonly number[] | undefined
|
||||||
readonly ownerType?: UnitType | undefined
|
readonly ownerType?: UnitType | undefined
|
||||||
readonly ownerId?: number | undefined
|
readonly ownerId?: number | undefined
|
||||||
readonly states?: readonly number[] | undefined
|
readonly states?: readonly number[] | undefined
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,12 @@ export {
|
||||||
D2gsS2cFramer,
|
D2gsS2cFramer,
|
||||||
resolveS2cPacketSize,
|
resolveS2cPacketSize,
|
||||||
} from './d2gs/framing-s2c.ts'
|
} from './d2gs/framing-s2c.ts'
|
||||||
|
export {
|
||||||
|
DEFAULT_MONSTER_COMP_BIT_WIDTHS,
|
||||||
|
MONSTATS2_COMP_BIT_WIDTHS_BY_HCIDX,
|
||||||
|
MONSTATS_HCIDX_COUNT,
|
||||||
|
getMonsterAnimCompBitWidths,
|
||||||
|
} from './d2gs/s2c/monstats2-comp-bits.ts'
|
||||||
|
|
||||||
import { ByteWriter } from './core/byte-writer.ts'
|
import { ByteWriter } from './core/byte-writer.ts'
|
||||||
import { resolveS2cPacketSize } from './d2gs/framing-s2c.ts'
|
import { resolveS2cPacketSize } from './d2gs/framing-s2c.ts'
|
||||||
|
|
|
||||||
|
|
@ -822,5 +822,138 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
|
||||||
expect(modded.resistances?.lightning).toBe(20)
|
expect(modded.resistances?.lightning).toBe(20)
|
||||||
expect(modded.resistances?.poison).toBe(75)
|
expect(modded.resistances?.poison).toBe(75)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maps online ClientUnit snapshots via clientUnitToMonsterTargetInfo with 1.13c nameKey, descKey, race, rank, modifiers, and immunities', async () => {
|
||||||
|
const { ClientUnit } = await import('../src/client/world/client-unit.ts')
|
||||||
|
const { clientUnitToMonsterTargetInfo } = await import('../src/client/ui-model/hud-model.ts')
|
||||||
|
|
||||||
|
// 1. Normal Skeleton (hcIdx = 0, Skeleton1): Undead
|
||||||
|
const skelUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 101,
|
||||||
|
classId: 0,
|
||||||
|
x: 5000,
|
||||||
|
y: 5000,
|
||||||
|
lifePct: 128,
|
||||||
|
})
|
||||||
|
const skelInfo = clientUnitToMonsterTargetInfo(skelUnit, 0)
|
||||||
|
expect(skelInfo.name).toBe('Skeleton')
|
||||||
|
expect(skelInfo.nameKey).toBe('Skeleton')
|
||||||
|
expect(skelInfo.rank).toBe('normal')
|
||||||
|
expect(skelInfo.isUndead).toBe(true)
|
||||||
|
expect(buildMonsterHoverSubtext(skelInfo, 'en').line1).toBe('Undead')
|
||||||
|
expect(buildMonsterHoverSubtext(skelInfo, 'zh').line1).toBe('不死的')
|
||||||
|
|
||||||
|
// 2. SuperUnique Bishibosh (hcIdx = 58 fallenshaman1, superUniqueId = 0, bossMods = [8, 9])
|
||||||
|
const bishUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 102,
|
||||||
|
classId: 58,
|
||||||
|
x: 5010,
|
||||||
|
y: 5010,
|
||||||
|
lifePct: 100,
|
||||||
|
flags: 0x0a,
|
||||||
|
isUnique: true,
|
||||||
|
isSuperUnique: true,
|
||||||
|
superUniqueId: 0,
|
||||||
|
bossMods: [8, 9],
|
||||||
|
})
|
||||||
|
const bishInfo = clientUnitToMonsterTargetInfo(bishUnit, 0)
|
||||||
|
expect(bishInfo.name).toBe('Bishibosh')
|
||||||
|
expect(bishInfo.nameKey).toBe('Bishibosh')
|
||||||
|
expect(bishInfo.rank).toBe('superunique')
|
||||||
|
expect(bishInfo.isDemon).toBe(true)
|
||||||
|
expect(resolveMonsterTitleAndColor(bishInfo, 'en').titleColor).toBe('gold')
|
||||||
|
expect(resolveMonsterTitleAndColor(bishInfo, 'zh').displayName).toBe('畢須博須')
|
||||||
|
const bishSubEn = buildMonsterHoverSubtext(bishInfo, 'en')
|
||||||
|
expect(bishSubEn.line1).toBe('Demon Magic Resistant Fire Enchanted Raises Fallen')
|
||||||
|
expect(bishSubEn.line2).toBe('Immune to Fire')
|
||||||
|
|
||||||
|
// 3. Ghostly Champion Fallen (hcIdx = 19 fallen1, flags = 0x44, bossMods = [36])
|
||||||
|
const champUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 103,
|
||||||
|
classId: 19,
|
||||||
|
x: 5020,
|
||||||
|
y: 5020,
|
||||||
|
lifePct: 128,
|
||||||
|
flags: 0x44,
|
||||||
|
bossMods: [36],
|
||||||
|
})
|
||||||
|
const champInfo = clientUnitToMonsterTargetInfo(champUnit, 0)
|
||||||
|
expect(champInfo.rank).toBe('champion')
|
||||||
|
expect(resolveMonsterTitleAndColor(champInfo, 'en')).toMatchObject({
|
||||||
|
displayName: 'Ghostly Fallen',
|
||||||
|
titleColor: 'blue',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Minion Fallen (hcIdx = 19 fallen1, flags = 0x10)
|
||||||
|
const minionUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 104,
|
||||||
|
classId: 19,
|
||||||
|
x: 5030,
|
||||||
|
y: 5030,
|
||||||
|
lifePct: 128,
|
||||||
|
flags: 0x10,
|
||||||
|
})
|
||||||
|
const minionInfo = clientUnitToMonsterTargetInfo(minionUnit, 0)
|
||||||
|
expect(minionInfo.rank).toBe('minion')
|
||||||
|
expect(buildMonsterHoverSubtext(minionInfo, 'en').line1).toBe('Demon Minion')
|
||||||
|
|
||||||
|
// 5. Act Boss Andariel (hcIdx = 156 andariel)
|
||||||
|
const andarielUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 105,
|
||||||
|
classId: 156,
|
||||||
|
x: 5040,
|
||||||
|
y: 5040,
|
||||||
|
lifePct: 128,
|
||||||
|
})
|
||||||
|
const andarielInfo = clientUnitToMonsterTargetInfo(andarielUnit, 0)
|
||||||
|
expect(andarielInfo.rank).toBe('boss')
|
||||||
|
expect(andarielInfo.isBoss).toBe(true)
|
||||||
|
expect(andarielInfo.isDemon).toBe(true)
|
||||||
|
expect(resolveMonsterTitleAndColor(andarielInfo, 'en').titleColor).toBe('gold')
|
||||||
|
|
||||||
|
// 6. Uber Diablo (hcIdx = 705 uberdiablo) suppresses modifiers and immunities (0x6facfedb & 0x6fb6fca0)
|
||||||
|
const uberUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 106,
|
||||||
|
classId: 705,
|
||||||
|
x: 5050,
|
||||||
|
y: 5050,
|
||||||
|
lifePct: 128,
|
||||||
|
bossMods: [8, 9, 17, 18],
|
||||||
|
})
|
||||||
|
const uberInfo = clientUnitToMonsterTargetInfo(uberUnit, 2)
|
||||||
|
expect(uberInfo.modifiers).toEqual([])
|
||||||
|
expect(uberInfo.resistances).toBeUndefined()
|
||||||
|
|
||||||
|
// 7. Random Unique Fallen (hcIdx = 19, flags = 0x08, nameSeed = 0x1234 -> wNameSeed = 0x34)
|
||||||
|
const randUniqueUnit = new ClientUnit({
|
||||||
|
unitType: 1,
|
||||||
|
id: 107,
|
||||||
|
classId: 19,
|
||||||
|
x: 5060,
|
||||||
|
y: 5060,
|
||||||
|
lifePct: 128,
|
||||||
|
flags: 0x08,
|
||||||
|
isUnique: true,
|
||||||
|
nameSeed: 0x1234,
|
||||||
|
bossMods: [5, 6],
|
||||||
|
})
|
||||||
|
const randUniqueInfo = clientUnitToMonsterTargetInfo(randUniqueUnit, 0)
|
||||||
|
expect(randUniqueInfo.rank).toBe('unique')
|
||||||
|
expect(randUniqueInfo.nameSeed).toBe(0x1234)
|
||||||
|
const enTitle = resolveMonsterTitleAndColor(randUniqueInfo, 'en')
|
||||||
|
const zhTitle = resolveMonsterTitleAndColor(randUniqueInfo, 'zh')
|
||||||
|
expect(enTitle.titleColor).toBe('gold')
|
||||||
|
expect(enTitle.displayName).not.toBe('Fallen')
|
||||||
|
expect(enTitle.displayName.split(' ').length).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(zhTitle.titleColor).toBe('gold')
|
||||||
|
expect(zhTitle.displayName).not.toBe('沉淪魔')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||||||
import {
|
import {
|
||||||
|
BitWriter,
|
||||||
decodeD2gsS2cPacket,
|
decodeD2gsS2cPacket,
|
||||||
decodeD2gsServerPacket,
|
decodeD2gsServerPacket,
|
||||||
|
getMonsterAnimCompBitWidths,
|
||||||
S2C_PACKET_SIZES,
|
S2C_PACKET_SIZES,
|
||||||
} from '../../src/netproto/index.ts'
|
} from '../../src/netproto/index.ts'
|
||||||
|
|
||||||
|
|
@ -183,6 +185,53 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => {
|
||||||
expect(corpseEv.unit.lifePct).toBe(0)
|
expect(corpseEv.unit.lifePct).toBe(0)
|
||||||
expect(corpseEv.unit.mode).toBe(12)
|
expect(corpseEv.unit.mode).toBe(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuperUnique Bishibosh (classId=58 fallenshaman1, superUniqueId=0) with hasAnimComp=1, bossMods=[8, 9], nameSeed=0x1234, ownerId=0xdeadbeef
|
||||||
|
const bw = new BitWriter()
|
||||||
|
bw.writeBits(1, 4) // rawMode = 1 (MONMODE_NEUTRAL)
|
||||||
|
bw.writeBit(1) // hasAnimComp = 1
|
||||||
|
const widths = getMonsterAnimCompBitWidths(1) // hcIdx=1 (returned / Skeleton, has multi-bit component widths)
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
bw.writeBits(i % (1 << widths[i]!), widths[i]!)
|
||||||
|
}
|
||||||
|
bw.writeBit(1) // hasMonsterFlags = 1
|
||||||
|
bw.writeBit(0) // champion = 0
|
||||||
|
bw.writeBit(1) // unique = 1
|
||||||
|
bw.writeBit(1) // superunique = 1
|
||||||
|
bw.writeBit(0) // minion = 0
|
||||||
|
bw.writeBit(0) // ghostly = 0
|
||||||
|
bw.writeBits(0, 16) // superUniqueId = 0 (Bishibosh)
|
||||||
|
bw.writeBits(8, 8) // mod 0 = 8 (Magic Resistant)
|
||||||
|
bw.writeBits(9, 8) // mod 1 = 9 (Fire Enchanted)
|
||||||
|
bw.writeBits(0, 8) // terminator = 0
|
||||||
|
bw.writeBits(0x1234, 16) // nameSeed = 0x1234
|
||||||
|
bw.writeBit(1) // hasOwner = 1
|
||||||
|
bw.writeBits(0xdeadbeef, 32) // ownerId = 0xdeadbeef
|
||||||
|
const bitBytes = bw.toUint8Array()
|
||||||
|
const totalLen = 13 + bitBytes.byteLength
|
||||||
|
const bossWriter = new ByteWriter(totalLen)
|
||||||
|
bossWriter.u8(0xac)
|
||||||
|
bossWriter.u32LE(777)
|
||||||
|
bossWriter.u16LE(1)
|
||||||
|
bossWriter.u16LE(5120)
|
||||||
|
bossWriter.u16LE(5140)
|
||||||
|
bossWriter.u8(96)
|
||||||
|
bossWriter.u8(totalLen)
|
||||||
|
bossWriter.bytes(bitBytes)
|
||||||
|
|
||||||
|
const bossEv = decodeD2gsServerPacket(bossWriter.toUint8Array())
|
||||||
|
expect(bossEv.type).toBe('UnitAssign')
|
||||||
|
if (bossEv.type === 'UnitAssign') {
|
||||||
|
expect(bossEv.unit.id).toBe(777)
|
||||||
|
expect(bossEv.unit.classId).toBe(1)
|
||||||
|
expect(bossEv.unit.isUnique).toBe(true)
|
||||||
|
expect(bossEv.unit.isSuperUnique).toBe(true)
|
||||||
|
expect(bossEv.unit.superUniqueId).toBe(0)
|
||||||
|
expect(bossEv.unit.bossMods).toEqual([8, 9])
|
||||||
|
expect(bossEv.unit.nameSeed).toBe(0x1234)
|
||||||
|
expect(bossEv.unit.ownerId).toBe(0xdeadbeef)
|
||||||
|
expect(bossEv.unit.compVariants).toHaveLength(16)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps 0x69/0x6A/0x6B state command index via D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE)', () => {
|
it('maps 0x69/0x6A/0x6B state command index via D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE)', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue