Merge branch 'fix/issue-559-monster-nameplate' into main (Closes #559)

# Conflicts:
#	src/common/units/monsters-data.ts
This commit is contained in:
troytt 2026-10-02 13:22:07 +00:00
commit 394369e9c1
12 changed files with 1183 additions and 27 deletions

View File

@ -350,6 +350,10 @@ async function main() {
for (const r of (await readTxt('SuperUniques.txt')).rows) {
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) {
addKey(r['LevelName'])
addKey(r['LevelWarp'])

View File

@ -11,7 +11,16 @@
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.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 { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
import { ClickButtonId } from '../../netproto/index.ts'
@ -30,7 +39,12 @@ import {
type GridPlacement,
type UiInventoryItem,
} 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 { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts'
import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts'
@ -1231,11 +1245,17 @@ export class HudModel implements HudCommandSink {
return
}
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
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 {
@ -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 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'
: unit.isUnique
: isUnique
? 'unique'
: unit.isChampion
: isChampion
? 'champion'
: 'normal'
const resolvedName =
unit.name && unit.name.trim().length > 0
? unit.name
: monsterDisplayName(`mon_${unit.classId}`, 'ENG') || `Monster #${unit.classId}`
: isMinion
? 'minion'
: isBoss
? 'boss'
: '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 {
name: resolvedName,
hcIdx: unit.classId,
...(unit.nameSeed !== undefined ? { nameSeed: unit.nameSeed } : {}),
...(nameKey !== undefined ? { nameKey } : {}),
...(descKey !== undefined ? { descKey } : {}),
hp: hpPct,
maxHp: 128,
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 } : {}),
}
}

View File

@ -55,7 +55,12 @@ import {
} from '../../common/units/npc-table.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_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 {
transmuteCubeItems,
@ -5085,6 +5090,8 @@ export function hitTestEscapeMenu(
export interface MonsterTargetHudInfo {
readonly name: string
readonly nameKey?: string | undefined
readonly hcIdx?: number | undefined
readonly nameSeed?: number | undefined
readonly hp: number
readonly maxHp: number
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 + " "`),
* 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(
target: MonsterTargetHudInfo,
@ -5194,6 +5202,35 @@ export function resolveMonsterTitleAndColor(
Boolean(target.isBoss)
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 {
displayName: 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`),
* 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' },
strong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
uniquextrastrong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },

View File

@ -84,6 +84,8 @@ export class ClientUnit {
isSuperUnique?: boolean | undefined
superUniqueId?: number | undefined
bossMods?: readonly number[] | undefined
nameSeed?: number | undefined
compVariants?: readonly number[] | undefined
ownerType?: UnitTypeName | undefined
ownerId?: number | undefined
overheadText?: string | undefined
@ -150,11 +152,13 @@ export class ClientUnit {
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
this.objectState = snapshot.objectState
this.interactType = snapshot.interactType
this.isChampion = snapshot.isChampion
this.isUnique = snapshot.isUnique
this.isSuperUnique = snapshot.isSuperUnique
this.isChampion = snapshot.isChampion ?? ((this.flags & 0x04) !== 0 ? true : undefined)
this.isUnique = snapshot.isUnique ?? ((this.flags & 0x08) !== 0 ? true : undefined)
this.isSuperUnique = snapshot.isSuperUnique ?? ((this.flags & 0x02) !== 0 ? true : undefined)
this.superUniqueId = snapshot.superUniqueId
this.bossMods = snapshot.bossMods
this.nameSeed = snapshot.nameSeed
this.compVariants = snapshot.compVariants
this.ownerType = snapshot.ownerType !== undefined ? unitTypeFromId(snapshot.ownerType) : undefined
this.ownerId = snapshot.ownerId
if (snapshot.states) {
@ -211,6 +215,9 @@ export class ClientUnit {
}
if (snapshot.flags !== undefined) {
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) {
this.warpId = snapshot.warpId
@ -238,6 +245,12 @@ export class ClientUnit {
if (snapshot.bossMods !== undefined) {
this.bossMods = snapshot.bossMods
}
if (snapshot.nameSeed !== undefined) {
this.nameSeed = snapshot.nameSeed
}
if (snapshot.compVariants !== undefined) {
this.compVariants = snapshot.compVariants
}
if (snapshot.ownerType !== undefined) {
this.ownerType = unitTypeFromId(snapshot.ownerType)
}

File diff suppressed because it is too large Load Diff

View File

@ -2135,6 +2135,232 @@ export function getMonsterKindByClassId(classId: number): MonsterKind | undefine
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`
* `UNITS_GetBaseVelocity` 0x6FDB1170, `UNITS_GetVelocity` 0x6FDB1B60, and `PathMisc.cpp`

View File

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

View File

@ -9,6 +9,7 @@ import { BitReader } from '../../core/bit-reader.ts'
import { ByteReader } from '../../core/byte-reader.ts'
import type { UnitType } from '../../domain/ids.ts'
import type { ServerEvent } from '../../domain/server-event.ts'
import { getMonsterAnimCompBitWidths } from './monstats2-comp-bits.ts'
function clampUnitType(raw: number): 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`,
* 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`)
* - 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 {
const r = new ByteReader(pkt)
@ -307,22 +316,93 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
let rawMode: number | undefined
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) {
const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen)))
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) {
let canReadFlags = true
if (hasAnimComp === 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()
let canReadOwner = hasMonsterFlags === 0
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 |= 0x04 // champion
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 |= 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,
lifePct,
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 } : {}),
},
}
}

View File

@ -232,6 +232,8 @@ export interface UnitSnapshot {
readonly isSuperUnique?: boolean | undefined
readonly superUniqueId?: number | undefined
readonly bossMods?: readonly number[] | undefined
readonly nameSeed?: number | undefined
readonly compVariants?: readonly number[] | undefined
readonly ownerType?: UnitType | undefined
readonly ownerId?: number | undefined
readonly states?: readonly number[] | undefined

View File

@ -226,6 +226,12 @@ export {
D2gsS2cFramer,
resolveS2cPacketSize,
} 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 { resolveS2cPacketSize } from './d2gs/framing-s2c.ts'

View File

@ -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?.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('沉淪魔')
})
})

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import {
BitWriter,
decodeD2gsS2cPacket,
decodeD2gsServerPacket,
getMonsterAnimCompBitWidths,
S2C_PACKET_SIZES,
} 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.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)', () => {