fix(frontend): align pre-game tick accumulator and anim rates with 1.13c ground truth

This commit is contained in:
troytt 2026-10-02 06:13:00 +00:00
parent 8dac79e4e4
commit f356c8b1d3
7 changed files with 989 additions and 93 deletions

View File

@ -7,6 +7,7 @@
"width": 2028,
"height": 614,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -255,6 +256,7 @@
"width": 1791,
"height": 974,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -503,6 +505,7 @@
"width": 1962,
"height": 83,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -751,6 +754,7 @@
"width": 2023,
"height": 115,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -999,6 +1003,7 @@
"width": 1998,
"height": 309,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -1247,6 +1252,7 @@
"width": 2313,
"height": 513,
"frameCount": 10,
"rate": 3,
"frames": [
{
"x": 0,
@ -1338,6 +1344,7 @@
"width": 2010,
"height": 397,
"frameCount": 26,
"rate": 3,
"frames": [
{
"x": 0,
@ -1554,6 +1561,7 @@
"width": 2010,
"height": 397,
"frameCount": 26,
"rate": 3,
"frames": [
{
"x": 0,
@ -1770,6 +1778,7 @@
"width": 1967,
"height": 469,
"frameCount": 18,
"rate": 2,
"frames": [
{
"x": 0,
@ -1922,6 +1931,7 @@
"width": 2043,
"height": 1251,
"frameCount": 54,
"rate": 1,
"frames": [
{
"x": 0,
@ -2362,6 +2372,7 @@
"width": 2005,
"height": 433,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -2612,6 +2623,7 @@
"width": 1601,
"height": 189,
"frameCount": 31,
"rate": 3,
"frames": [
{
"x": 0,
@ -2868,6 +2880,7 @@
"width": 1601,
"height": 189,
"frameCount": 31,
"rate": 3,
"frames": [
{
"x": 0,
@ -3124,6 +3137,7 @@
"width": 2001,
"height": 375,
"frameCount": 31,
"rate": 2,
"frames": [
{
"x": 0,
@ -3380,6 +3394,7 @@
"width": 2034,
"height": 805,
"frameCount": 91,
"rate": 1,
"frames": [
{
"x": 0,
@ -4116,6 +4131,7 @@
"width": 1983,
"height": 382,
"frameCount": 51,
"rate": 1,
"frames": [
{
"x": 0,
@ -4534,6 +4550,7 @@
"width": 708,
"height": 183,
"frameCount": 12,
"rate": 3,
"frames": [
{
"x": 0,
@ -4638,6 +4655,7 @@
"width": 708,
"height": 183,
"frameCount": 12,
"rate": 3,
"frames": [
{
"x": 0,
@ -4742,6 +4760,7 @@
"width": 1566,
"height": 233,
"frameCount": 12,
"rate": 2,
"frames": [
{
"x": 0,
@ -4846,6 +4865,7 @@
"width": 1947,
"height": 485,
"frameCount": 12,
"rate": 2,
"frames": [
{
"x": 0,
@ -4950,6 +4970,7 @@
"width": 1998,
"height": 676,
"frameCount": 38,
"rate": 1,
"frames": [
{
"x": 0,
@ -5262,6 +5283,7 @@
"width": 1919,
"height": 482,
"frameCount": 38,
"rate": 1,
"frames": [
{
"x": 0,
@ -5574,6 +5596,7 @@
"width": 1951,
"height": 448,
"frameCount": 28,
"rate": 1,
"frames": [
{
"x": 0,
@ -5806,6 +5829,7 @@
"width": 2002,
"height": 472,
"frameCount": 28,
"rate": 1,
"frames": [
{
"x": 0,
@ -6040,6 +6064,7 @@
"width": 1403,
"height": 183,
"frameCount": 16,
"rate": 2,
"frames": [
{
"x": 0,
@ -6176,6 +6201,7 @@
"width": 1403,
"height": 183,
"frameCount": 16,
"rate": 2,
"frames": [
{
"x": 0,
@ -6312,6 +6338,7 @@
"width": 1989,
"height": 404,
"frameCount": 26,
"rate": 1,
"frames": [
{
"x": 0,
@ -6528,6 +6555,7 @@
"width": 2033,
"height": 1122,
"frameCount": 64,
"rate": 1,
"frames": [
{
"x": 0,
@ -7048,6 +7076,7 @@
"width": 1813,
"height": 201,
"frameCount": 19,
"rate": 1,
"frames": [
{
"x": 0,
@ -7210,6 +7239,7 @@
"width": 2009,
"height": 359,
"frameCount": 26,
"rate": 2,
"frames": [
{
"x": 0,
@ -7426,6 +7456,7 @@
"width": 2009,
"height": 359,
"frameCount": 26,
"rate": 2,
"frames": [
{
"x": 0,
@ -7642,6 +7673,7 @@
"width": 1190,
"height": 173,
"frameCount": 9,
"rate": 2,
"frames": [
{
"x": 0,
@ -7722,6 +7754,7 @@
"width": 2014,
"height": 1161,
"frameCount": 80,
"rate": 1,
"frames": [
{
"x": 0,
@ -8370,6 +8403,7 @@
"width": 2045,
"height": 597,
"frameCount": 40,
"rate": 1,
"frames": [
{
"x": 0,
@ -8700,6 +8734,7 @@
"width": 2035,
"height": 333,
"frameCount": 32,
"rate": 2,
"frames": [
{
"x": 0,
@ -8964,6 +8999,7 @@
"width": 2035,
"height": 333,
"frameCount": 32,
"rate": 2,
"frames": [
{
"x": 0,
@ -9228,6 +9264,7 @@
"width": 1306,
"height": 209,
"frameCount": 12,
"rate": 1,
"frames": [
{
"x": 0,
@ -9332,6 +9369,7 @@
"width": 1263,
"height": 77,
"frameCount": 12,
"rate": 1,
"frames": [
{
"x": 0,
@ -9436,6 +9474,7 @@
"width": 2000,
"height": 606,
"frameCount": 52,
"rate": 1,
"frames": [
{
"x": 0,
@ -9860,6 +9899,7 @@
"width": 1299,
"height": 68,
"frameCount": 52,
"rate": 1,
"frames": [
{
"x": 0,
@ -10284,6 +10324,7 @@
"width": 2013,
"height": 388,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -10532,6 +10573,7 @@
"width": 991,
"height": 73,
"frameCount": 30,
"rate": 1,
"frames": [
{
"x": 0,
@ -10782,6 +10824,7 @@
"width": 1838,
"height": 199,
"frameCount": 21,
"rate": 2,
"frames": [
{
"x": 0,
@ -10958,6 +11001,7 @@
"width": 1838,
"height": 199,
"frameCount": 21,
"rate": 2,
"frames": [
{
"x": 0,
@ -11134,6 +11178,7 @@
"width": 2034,
"height": 483,
"frameCount": 21,
"rate": 2,
"frames": [
{
"x": 0,
@ -11310,6 +11355,7 @@
"width": 2023,
"height": 1666,
"frameCount": 121,
"rate": 1,
"frames": [
{
"x": 0,
@ -12286,6 +12332,7 @@
"width": 2045,
"height": 460,
"frameCount": 40,
"rate": 1,
"frames": [
{
"x": 0,
@ -12617,6 +12664,7 @@
"width": 464,
"height": 75,
"frameCount": 16,
"animRate": 80,
"frames": [
{
"x": 0,
@ -12753,6 +12801,7 @@
"width": 276,
"height": 76,
"frameCount": 12,
"animRate": 64,
"frames": [
{
"x": 0,
@ -12857,6 +12906,7 @@
"width": 448,
"height": 72,
"frameCount": 16,
"animRate": 80,
"frames": [
{
"x": 0,
@ -12993,6 +13043,7 @@
"width": 768,
"height": 78,
"frameCount": 16,
"animRate": 80,
"frames": [
{
"x": 0,
@ -13129,6 +13180,7 @@
"width": 528,
"height": 72,
"frameCount": 16,
"animRate": 128,
"frames": [
{
"x": 0,
@ -13265,6 +13317,7 @@
"width": 512,
"height": 68,
"frameCount": 16,
"animRate": 80,
"frames": [
{
"x": 0,
@ -13401,6 +13454,7 @@
"width": 340,
"height": 75,
"frameCount": 10,
"animRate": 96,
"frames": [
{
"x": 0,

View File

@ -12,7 +12,7 @@
* and English TBL strings into `public/ui/frontend/`.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { MpqArchive } from '../../common/mpq/archive.ts'
import { fileSource } from '../io/file-source.ts'
@ -45,6 +45,8 @@ export interface PackedAnimStripMeta {
readonly width: number
readonly height: number
readonly frameCount: number
readonly rate?: number
readonly animRate?: number
readonly frames: readonly PackedStripFrameMeta[]
}
@ -228,6 +230,10 @@ function packFramesToGrid(
file: string,
frames: readonly SpriteFrame[],
maxRowWidth = 2048,
options?: {
readonly rate?: number
readonly animRate?: number
},
): {
meta: PackedAnimStripMeta
pixels: Uint8Array
@ -267,6 +273,8 @@ function packFramesToGrid(
width: maxW,
height: totalH,
frameCount: frames.length,
...(options?.rate !== undefined ? { rate: options.rate } : {}),
...(options?.animRate !== undefined ? { animRate: options.animRate } : {}),
frames: frameMetas,
},
pixels,
@ -334,12 +342,21 @@ function buildFontAtlas(
}
}
function resolveDefaultArchiveDir(argDir?: string): string {
if (argDir && existsSync(argDir)) return argDir
if (process.env.D2_DATA_DIR && existsSync(process.env.D2_DATA_DIR)) return process.env.D2_DATA_DIR
if (existsSync('samples/d2')) return 'samples/d2'
if (existsSync('/usr/local/google/home/taodao/d2-data')) return '/usr/local/google/home/taodao/d2-data'
return argDir ?? 'samples/d2'
}
async function main(): Promise<void> {
const t0 = performance.now()
console.log('Mounting Diablo II v1.13c MPQ archives...')
const archiveDir = resolveDefaultArchiveDir(process.argv[2])
console.log(`Mounting Diablo II v1.13c MPQ archives from ${archiveDir}...`)
const archives = new MountedArchives()
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`)))
archives.add(m, await MpqArchive.open(await fileSource(join(archiveDir, m))))
}
const skyPl2 = decodePl2(await archives.read('data\\global\\palette\\sky\\pal.pl2'))
@ -553,7 +570,7 @@ async function main(): Promise<void> {
logoBlackLDc6.groups[0]!.frames,
logoBlackRDc6.groups[0]!.frames,
)
const packedBlack = packFramesToGrid('logo-main-black.png', blackFrames, 2048)
const packedBlack = packFramesToGrid('logo-main-black.png', blackFrames, 2048, { rate: 1 })
savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true)
animStrips['logoMainBlack'] = packedBlack.meta
@ -561,7 +578,7 @@ async function main(): Promise<void> {
logoFireLDc6.groups[0]!.frames,
logoFireRDc6.groups[0]!.frames,
)
const packedFire = packFramesToGrid('logo-main-fire.png', fireFrames, 2048)
const packedFire = packFramesToGrid('logo-main-fire.png', fireFrames, 2048, { rate: 1 })
savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true)
animStrips['logoMainFire'] = packedFire.meta
}
@ -570,11 +587,11 @@ async function main(): Promise<void> {
const bnetBlackDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBlackBnet.dc6')
const bnetFireDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBnet.dc6')
{
const packedBlack = packFramesToGrid('logo-bnet-black.png', bnetBlackDc6.groups[0]!.frames, 2048)
const packedBlack = packFramesToGrid('logo-bnet-black.png', bnetBlackDc6.groups[0]!.frames, 2048, { rate: 1 })
savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true)
animStrips['logoBnetBlack'] = packedBlack.meta
const packedFire = packFramesToGrid('logo-bnet-fire.png', bnetFireDc6.groups[0]!.frames, 2048)
const packedFire = packFramesToGrid('logo-bnet-fire.png', bnetFireDc6.groups[0]!.frames, 2048, { rate: 1 })
savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true)
animStrips['logoBnetFire'] = packedFire.meta
}
@ -582,7 +599,7 @@ async function main(): Promise<void> {
// Campfire `fire.dc6` at control anchor (345, 454) on charactercreationscreenEXP.dc6
const fireDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\fire.dc6')
{
const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048)
const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048, { rate: 1 })
savePng(packed.meta.file, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
animStrips['campfireFlame'] = packed.meta
}
@ -591,7 +608,7 @@ async function main(): Promise<void> {
// 5. 7-Class Campfire Multi-State Animations (fechar/pal.pl2)
// ============================================================================
console.log('Packing 7-class campfire multi-state animations...')
// Per D2Launch.dll 0x6fa68df8 & 0x6fa5d780:
// Per D2Launch.dll 0x6fa68df8, 0x6fa60c08 & 0x6fa5d780:
// Note: ptAnimTable != NULL does NOT subtract frame0.offset!
interface ClassCampfireSpec {
readonly id: string
@ -600,6 +617,7 @@ async function main(): Promise<void> {
readonly states: readonly {
readonly key: string
readonly base: string
readonly rate: number
readonly overlay?: string
readonly additiveOverlay?: boolean
}[]
@ -611,11 +629,11 @@ async function main(): Promise<void> {
dir: 'amazon',
prefix: 'am',
states: [
{ key: 'nu1', base: 'amnu1.dc6' },
{ key: 'nu2', base: 'amnu2.dc6' },
{ key: 'nu3', base: 'amnu3.dc6' },
{ key: 'fw', base: 'amfw.dc6', overlay: 'amfws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'ambw.dc6' },
{ key: 'nu1', base: 'amnu1.dc6', rate: 3 },
{ key: 'nu2', base: 'amnu2.dc6', rate: 3 },
{ key: 'nu3', base: 'amnu3.dc6', rate: 2 },
{ key: 'fw', base: 'amfw.dc6', rate: 1, overlay: 'amfws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'ambw.dc6', rate: 1 },
],
},
{
@ -623,11 +641,11 @@ async function main(): Promise<void> {
dir: 'assassin',
prefix: 'as',
states: [
{ key: 'nu1', base: 'asnu1.dc6' },
{ key: 'nu2', base: 'asnu2.dc6' },
{ key: 'nu3', base: 'asnu3.dc6' },
{ key: 'fw', base: 'asfw.dc6' },
{ key: 'bw', base: 'asbw.dc6' },
{ key: 'nu1', base: 'asnu1.dc6', rate: 3 },
{ key: 'nu2', base: 'asnu2.dc6', rate: 3 },
{ key: 'nu3', base: 'asnu3.dc6', rate: 2 },
{ key: 'fw', base: 'asfw.dc6', rate: 1 },
{ key: 'bw', base: 'asbw.dc6', rate: 1 },
],
},
{
@ -635,11 +653,11 @@ async function main(): Promise<void> {
dir: 'necromancer',
prefix: 'ne',
states: [
{ key: 'nu1', base: 'nenu1.dc6' },
{ key: 'nu2', base: 'nenu2.dc6' },
{ key: 'nu3', base: 'nenu3.dc6', overlay: 'nenu3s.dc6', additiveOverlay: true },
{ key: 'fw', base: 'nefw.dc6', overlay: 'nefws.dc6', additiveOverlay: true },
{ key: 'bw', base: 'nebw.dc6', overlay: 'nebws.dc6', additiveOverlay: true },
{ key: 'nu1', base: 'nenu1.dc6', rate: 3 },
{ key: 'nu2', base: 'nenu2.dc6', rate: 3 },
{ key: 'nu3', base: 'nenu3.dc6', rate: 2, overlay: 'nenu3s.dc6', additiveOverlay: true },
{ key: 'fw', base: 'nefw.dc6', rate: 1, overlay: 'nefws.dc6', additiveOverlay: true },
{ key: 'bw', base: 'nebw.dc6', rate: 1, overlay: 'nebws.dc6', additiveOverlay: true },
],
},
{
@ -647,11 +665,11 @@ async function main(): Promise<void> {
dir: 'barbarian',
prefix: 'ba',
states: [
{ key: 'nu1', base: 'banu1.dc6' },
{ key: 'nu2', base: 'banu2.dc6' },
{ key: 'nu3', base: 'banu3.dc6' },
{ key: 'fw', base: 'bafw.dc6', overlay: 'bafws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'babw.dc6' },
{ key: 'nu1', base: 'banu1.dc6', rate: 2 },
{ key: 'nu2', base: 'banu2.dc6', rate: 2 },
{ key: 'nu3', base: 'banu3.dc6', rate: 1 },
{ key: 'fw', base: 'bafw.dc6', rate: 1, overlay: 'bafws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'babw.dc6', rate: 1 },
],
},
{
@ -659,11 +677,11 @@ async function main(): Promise<void> {
dir: 'paladin',
prefix: 'pa',
states: [
{ key: 'nu1', base: 'panu1.dc6' },
{ key: 'nu2', base: 'panu2.dc6' },
{ key: 'nu3', base: 'panu3.dc6' },
{ key: 'fw', base: 'pafw.dc6', overlay: 'pafws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'pabw.dc6' },
{ key: 'nu1', base: 'panu1.dc6', rate: 2 },
{ key: 'nu2', base: 'panu2.dc6', rate: 2 },
{ key: 'nu3', base: 'panu3.dc6', rate: 2 },
{ key: 'fw', base: 'pafw.dc6', rate: 1, overlay: 'pafws.dc6', additiveOverlay: false },
{ key: 'bw', base: 'pabw.dc6', rate: 1 },
],
},
{
@ -671,11 +689,11 @@ async function main(): Promise<void> {
dir: 'sorceress',
prefix: 'so',
states: [
{ key: 'nu1', base: 'sonu1.dc6' },
{ key: 'nu2', base: 'sonu2.dc6' },
{ key: 'nu3', base: 'sonu3.dc6', overlay: 'sonu3s.dc6', additiveOverlay: true },
{ key: 'fw', base: 'sofw.dc6', overlay: 'sofws.dc6', additiveOverlay: true },
{ key: 'bw', base: 'sobw.dc6', overlay: 'sobws.dc6', additiveOverlay: true },
{ key: 'nu1', base: 'sonu1.dc6', rate: 2 },
{ key: 'nu2', base: 'sonu2.dc6', rate: 2 },
{ key: 'nu3', base: 'sonu3.dc6', rate: 1, overlay: 'sonu3s.dc6', additiveOverlay: true },
{ key: 'fw', base: 'sofw.dc6', rate: 1, overlay: 'sofws.dc6', additiveOverlay: true },
{ key: 'bw', base: 'sobw.dc6', rate: 1, overlay: 'sobws.dc6', additiveOverlay: true },
],
},
{
@ -683,11 +701,11 @@ async function main(): Promise<void> {
dir: 'druid',
prefix: 'dz',
states: [
{ key: 'nu1', base: 'dznu1.dc6' },
{ key: 'nu2', base: 'dznu2.dc6' },
{ key: 'nu3', base: 'dznu3.dc6' },
{ key: 'fw', base: 'dzfw.dc6' },
{ key: 'bw', base: 'dzbw.dc6' },
{ key: 'nu1', base: 'dznu1.dc6', rate: 2 },
{ key: 'nu2', base: 'dznu2.dc6', rate: 2 },
{ key: 'nu3', base: 'dznu3.dc6', rate: 2 },
{ key: 'fw', base: 'dzfw.dc6', rate: 1 },
{ key: 'bw', base: 'dzbw.dc6', rate: 1 },
],
},
]
@ -705,18 +723,18 @@ async function main(): Promise<void> {
if (ovDc6 && !st.additiveOverlay) {
const composited = compositeOverlayFrames(baseDc6.groups[0]!.frames, ovDc6.groups[0]!.frames)
const relFile = `campfire/${cls.id}-${st.key}.png`
const packed = packFramesToGrid(relFile, composited, 2048)
const packed = packFramesToGrid(relFile, composited, 2048, { rate: st.rate })
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![st.key] = packed.meta
} else {
const relFile = `campfire/${cls.id}-${st.key}.png`
const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048)
const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048, { rate: st.rate })
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![st.key] = packed.meta
if (ovDc6 && st.additiveOverlay) {
const ovRelFile = `campfire/${cls.id}-${st.key}-ov.png`
const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048)
const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048, { rate: st.rate })
savePng(ovRelFile, ovPacked.meta.width, ovPacked.meta.height, ovPacked.pixels, fecharPl2.rgb, true)
campfireClasses[cls.id]![`${st.key}_ov`] = ovPacked.meta
}
@ -729,7 +747,7 @@ async function main(): Promise<void> {
// ============================================================================
console.log('Packing 10-frame Door Opening Loading Screen...')
const loadingDc6 = await decodeUiDc6('data\\global\\ui\\Loading\\Loadingscreen.dc6')
const loadingPacked = packFramesToGrid('loading-door-strip.png', loadingDc6.groups[0]!.frames, 2560)
const loadingPacked = packFramesToGrid('loading-door-strip.png', loadingDc6.groups[0]!.frames, 2560, { rate: 3 })
savePng(
loadingPacked.meta.file,
loadingPacked.meta.width,
@ -751,7 +769,7 @@ async function main(): Promise<void> {
// Group 0 in loadCharacterSheet is direction 0 (dir64=0 -> dccDir=4 = straight South towards camera)
const southFrames = tnSheet.sheet.groups[0]!.frames
const relFile = `avatars/char-${token}-tn.png`
const packed = packFramesToGrid(relFile, southFrames, 2048)
const packed = packFramesToGrid(relFile, southFrames, 2048, { animRate: tnSheet.speed ?? 256 })
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, skyPl2.rgb, true)
charAvatars[token] = packed.meta
}
@ -936,9 +954,10 @@ async function main(): Promise<void> {
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
const assetsFrontendDir = join(process.cwd(), 'public', 'assets', 'ui', 'frontend')
const { cpSync } = await import('node:fs')
mkdirSync(assetsFrontendDir, { recursive: true })
cpSync(outDir, assetsFrontendDir, { recursive: true, force: true })
if (existsSync(assetsFrontendDir)) {
const { cpSync } = await import('node:fs')
cpSync(outDir, assetsFrontendDir, { recursive: true, force: true })
}
console.log(
`Successfully packed ${decodedCount} DC6 sheets + 7 TN character avatars + ${FONT_NAMES.length * FONT_COLORS.length} colored font atlases in ${(performance.now() - t0).toFixed(0)} ms!`,
)

View File

@ -31,6 +31,15 @@ export type CampfireAnimPhase = 'nu1' | 'nu2' | 'fw' | 'nu3' | 'bw'
export type GameDifficulty = 'normal' | 'nightmare' | 'hell'
/** Fixed 25fps front-end tick budget (40ms per tick, `D2Win.dll` @ `0x6f8f8980`). */
export const FRONTEND_TICK_MS = 40
/** Maximum catch-up ticks allowed in a single frame step to prevent spiral-of-death bursts. */
export const FRONTEND_MAX_CATCHUP_TICKS = 5
/** Maximum frame delta (in milliseconds) accumulated in a single frame step (`5 * 40ms = 200ms`). */
export const FRONTEND_MAX_FRAME_DELTA_MS = FRONTEND_TICK_MS * FRONTEND_MAX_CATCHUP_TICKS
export interface CampfireClassSlotState {
readonly id: CampfireClassId
readonly heroToken: HeroToken
@ -43,7 +52,22 @@ export interface CampfireClassSlotState {
readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
phase: CampfireAnimPhase
frameIndex: number
/** Ticks elapsed in the current animation frame (`0 .. rate - 1`). */
phaseTicks: number
frameCount: Record<CampfireAnimPhase, number>
/** Per-phase tick multiplier (`durMs = rate * 40ms`, `D2Launch.dll` @ `0x6fa60c08`). */
frameRate: Record<CampfireAnimPhase, number>
}
export interface HeroAvatarAnimState {
readonly heroToken: HeroToken
frameCount: number
/** 8.8 fixed-point COF animation speed (`D2Win.dll` @ `0x6f8ec48b`). */
animRate: number
/** 8.8 fixed-point accumulator (`acc += animRate` once per 40ms tick, `D2Win.dll` @ `0x6f8ee836`). */
acc: number
/** Current frame index (`acc >> 8`, wraps when `frame >= framesPerDir - 1`). */
frameIndex: number
}
export interface RosterCharacter {
@ -90,6 +114,10 @@ export interface BnetFlowState {
stageTicks: number
/** Global animation tick counter (25fps). */
globalTick: number
/** Fractional millisecond accumulator for fixed 25fps (40ms/tick) stepping. */
tickAccumulatorMs: number
/** Last `requestAnimationFrame` timestamp (ms) observed by `stepFlowFrame`, or `null` initially. */
lastFrameTimeMs: number | null
/** Whether live D2OnlineFlow is active (falls back to mock when false). */
onlineConnected?: boolean | undefined
@ -108,8 +136,9 @@ export interface BnetFlowState {
isLadder: boolean
charCreateError: string | null
// Stage 4: 8-Slot Character Selection
// Stage 4: 8-Slot Character Selection & Lobby TN Avatar 8.8 Fixed-Point Accumulators
roster: RosterCharacter[]
avatarAnims: Record<HeroToken, HeroAvatarAnimState>
selectedSlotIndex: number
deleteConfirmOpen: boolean
@ -156,6 +185,18 @@ export const CAMPFIRE_CLASS_ORDER: readonly CampfireClassId[] = [
'dz',
] as const
export const HERO_TOKENS: readonly HeroToken[] = [
'am',
'ai',
'ne',
'ba',
'pa',
'so',
'dz',
] as const
export const HERO_TOKEN_ORDER: readonly HeroToken[] = HERO_TOKENS
export const CAMPFIRE_CLASS_SPECS: Readonly<
Record<
CampfireClassId,
@ -167,6 +208,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
readonly anchorY: number
readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
readonly defaultFrames: Record<CampfireAnimPhase, number>
/** 1.13c `D2Launch.dll` `@ 0x6fa60c08` per-mode tick multiplier (`durMs = rate * 40ms`). */
readonly defaultRates: Record<CampfireAnimPhase, number>
}
>
> = {
@ -177,7 +220,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 100,
anchorY: 337,
hitBox: { x: 56, y: 153, w: 88, h: 184 },
defaultFrames: { nu1: 35, nu2: 24, fw: 51, nu3: 10, bw: 32 },
defaultFrames: { nu1: 26, nu2: 56, fw: 28, nu3: 12, bw: 22 },
defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
},
as: {
id: 'as',
@ -186,7 +230,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 232,
anchorY: 364,
hitBox: { x: 188, y: 180, w: 88, h: 184 },
defaultFrames: { nu1: 42, nu2: 8, fw: 38, nu3: 16, bw: 29 },
defaultFrames: { nu1: 30, nu2: 45, fw: 18, nu3: 16, bw: 17 },
defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
},
ne: {
id: 'ne',
@ -195,7 +240,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 301,
anchorY: 333,
hitBox: { x: 257, y: 149, w: 88, h: 184 },
defaultFrames: { nu1: 12, nu2: 8, fw: 52, nu3: 16, bw: 28 },
defaultFrames: { nu1: 12, nu2: 39, fw: 26, nu3: 16, bw: 15 },
defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
},
ba: {
id: 'ba',
@ -204,7 +250,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 400,
anchorY: 330,
hitBox: { x: 356, y: 146, w: 88, h: 184 },
defaultFrames: { nu1: 38, nu2: 8, fw: 63, nu3: 16, bw: 32 },
defaultFrames: { nu1: 16, nu2: 51, fw: 32, nu3: 16, bw: 17 },
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
},
pa: {
id: 'pa',
@ -213,7 +260,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 521,
anchorY: 339,
hitBox: { x: 477, y: 155, w: 88, h: 184 },
defaultFrames: { nu1: 37, nu2: 8, fw: 61, nu3: 24, bw: 28 },
defaultFrames: { nu1: 19, nu2: 65, fw: 28, nu3: 13, bw: 15 },
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
},
so: {
id: 'so',
@ -222,7 +270,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 626,
anchorY: 353,
hitBox: { x: 582, y: 169, w: 88, h: 184 },
defaultFrames: { nu1: 30, nu2: 8, fw: 50, nu3: 26, bw: 28 },
defaultFrames: { nu1: 18, nu2: 52, fw: 28, nu3: 16, bw: 16 },
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
},
dz: {
id: 'dz',
@ -231,10 +280,69 @@ export const CAMPFIRE_CLASS_SPECS: Readonly<
anchorX: 720,
anchorY: 370,
hitBox: { x: 676, y: 186, w: 88, h: 184 },
defaultFrames: { nu1: 30, nu2: 18, fw: 49, nu3: 16, bw: 33 },
defaultFrames: { nu1: 26, nu2: 53, fw: 25, nu3: 16, bw: 17 },
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
},
}
/**
* 1.13c `D2Launch.dll` `@ 0x6fa60c08` campfire per-class, per-phase rate table (`durMs = rate * 40ms`).
*/
export const CAMPFIRE_ANIM_RATES: Readonly<
Record<CampfireClassId, Readonly<Record<CampfireAnimPhase, number>>>
> = {
am: CAMPFIRE_CLASS_SPECS.am.defaultRates,
as: CAMPFIRE_CLASS_SPECS.as.defaultRates,
ne: CAMPFIRE_CLASS_SPECS.ne.defaultRates,
ba: CAMPFIRE_CLASS_SPECS.ba.defaultRates,
pa: CAMPFIRE_CLASS_SPECS.pa.defaultRates,
so: CAMPFIRE_CLASS_SPECS.so.defaultRates,
dz: CAMPFIRE_CLASS_SPECS.dz.defaultRates,
}
/**
* 1.13c Town Neutral (`TN` `HTH`) COF `frameCount` (`framesPerDir`) and 8.8 fixed-point `animRate` values
* (`D2Win.dll` `@ 0x6f8ec48b`, `0x6f8ee836`):
* - `am`, `so`, `ne`, `ba`: `80` (16 frames, ~7.8125 fps)
* - `pa`: `128` (16 frames, 12.5 fps)
* - `dz`: `96` (10 frames, 9.375 fps)
* - `ai` (`as`): `64` (12 frames, 6.25 fps)
*/
export const HERO_TN_ANIM_SPECS: Readonly<
Record<HeroToken, { readonly frameCount: number; readonly animRate: number }>
> = {
am: { frameCount: 16, animRate: 80 },
ai: { frameCount: 12, animRate: 64 },
ne: { frameCount: 16, animRate: 80 },
ba: { frameCount: 16, animRate: 80 },
pa: { frameCount: 16, animRate: 128 },
so: { frameCount: 16, animRate: 80 },
dz: { frameCount: 10, animRate: 96 },
}
export function createInitialHeroAvatarAnims(): Record<HeroToken, HeroAvatarAnimState> {
const avatarAnims = {} as Record<HeroToken, HeroAvatarAnimState>
for (const token of HERO_TOKENS) {
const spec = HERO_TN_ANIM_SPECS[token]
avatarAnims[token] = {
heroToken: token,
frameCount: spec.frameCount,
animRate: spec.animRate,
acc: 0,
frameIndex: 0,
}
}
return avatarAnims
}
export function resetHeroAvatarAnims(state: BnetFlowState): void {
for (const token of HERO_TOKENS) {
const anim = state.avatarAnims[token]
anim.acc = 0
anim.frameIndex = 0
}
}
export function createInitialFlowState(initialRoster: RosterCharacter[] = []): BnetFlowState {
const campfireClasses = {} as Record<CampfireClassId, CampfireClassSlotState>
for (const id of CAMPFIRE_CLASS_ORDER) {
@ -248,7 +356,9 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
hitBox: spec.hitBox,
phase: 'nu1',
frameIndex: 0,
phaseTicks: 0,
frameCount: { ...spec.defaultFrames },
frameRate: { ...spec.defaultRates },
}
}
@ -257,6 +367,8 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
gateway: 'U.S. East',
stageTicks: 0,
globalTick: 0,
tickAccumulatorMs: 0,
lastFrameTimeMs: null,
// No prefilled credentials: the user (or a configured bot) must type them.
accountName: '',
@ -273,6 +385,7 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
charCreateError: null,
roster: [...initialRoster],
avatarAnims: createInitialHeroAvatarAnims(),
selectedSlotIndex: 0,
deleteConfirmOpen: false,
@ -355,6 +468,7 @@ export function submitBattleNetLogin(state: BnetFlowState): boolean {
state.stage = 'char_select'
state.selectedSlotIndex = 0
state.activeInput = null
resetHeroAvatarAnims(state)
}
return true
}
@ -377,6 +491,7 @@ export function enterCharCreate(state: BnetFlowState): void {
const slot = state.campfireClasses[id]
slot.phase = 'nu1'
slot.frameIndex = 0
slot.phaseTicks = 0
}
}
@ -390,11 +505,13 @@ export function hoverCampfireClass(state: BnetFlowState, classId: CampfireClassI
if (prev && state.campfireClasses[prev].phase === 'nu2') {
state.campfireClasses[prev].phase = 'nu1'
state.campfireClasses[prev].frameIndex = 0
state.campfireClasses[prev].phaseTicks = 0
}
state.hoveredClass = classId
if (classId && state.campfireClasses[classId].phase === 'nu1') {
state.campfireClasses[classId].phase = 'nu2'
state.campfireClasses[classId].frameIndex = 0
state.campfireClasses[classId].phaseTicks = 0
}
}
@ -415,9 +532,11 @@ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClass
if (prevSlot.phase === 'fw' || prevSlot.phase === 'nu3') {
prevSlot.phase = 'bw'
prevSlot.frameIndex = 0
prevSlot.phaseTicks = 0
} else {
prevSlot.phase = 'nu1'
prevSlot.frameIndex = 0
prevSlot.phaseTicks = 0
}
}
state.selectedClass = classId
@ -425,6 +544,7 @@ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClass
const nextSlot = state.campfireClasses[classId]
nextSlot.phase = 'fw'
nextSlot.frameIndex = 0
nextSlot.phaseTicks = 0
state.activeInput = 'charName'
}
@ -464,6 +584,7 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n
state.stage = 'char_select'
state.stageTicks = 0
state.activeInput = null
resetHeroAvatarAnims(state)
return newChar
}
@ -539,6 +660,7 @@ export function confirmCharacterSelect(state: BnetFlowState): boolean {
state.difficulty = 'normal'
}
state.activeInput = 'gameName'
resetHeroAvatarAnims(state)
return true
}
@ -588,10 +710,43 @@ export function submitCreateGame(state: BnetFlowState): boolean {
state.stage = 'door_loading'
state.stageTicks = 0
state.doorFrame = 0
state.sceneReady = false
state.activeInput = null
return true
}
/**
* Compute the current looping frame index for a `D2Win.dll` `AnimImage` control
* (`0x6f8f0a50..0x6f8f0c3a`):
* - Advances 1 frame every `rate` ticks (40ms * `rate` per frame).
* - Wraps at `framesPerDir - 1` (`0x6f8f0ac6: lea eax, [ecx - 1]; cmp [esi + 0x5c], eax; jl ...; mov [esi + 0x5c], 0`).
*/
export function getAnimImageLoopFrame(tick: number, framesPerDir: number, rate = 1): number {
const loopLen = Math.max(1, framesPerDir - 1)
const stepRate = Math.max(1, rate | 0)
return Math.floor(Math.max(0, tick) / stepRate) % loopLen
}
/**
* Advance a single Character Selection / Lobby `TN` `HTH` hero avatar by 1 tick (40ms)
* per `D2Win.dll` (`0x6f8ee81b..0x6f8ee850`):
* - `acc += animRate` (8.8 fixed-point COF speed)
* - `frame = acc >> 8`
* - If `frame >= framesPerDir - 1`, wrap `acc = 0` (`frame = 0`).
*/
export function stepHeroAvatarAnim(anim: HeroAvatarAnimState): void {
const wrapFrame = Math.max(1, anim.frameCount - 1)
const nextAcc = anim.acc + anim.animRate
const nextFrame = nextAcc >> 8
if (nextFrame >= wrapFrame) {
anim.acc = 0
anim.frameIndex = 0
} else {
anim.acc = nextAcc
anim.frameIndex = nextFrame
}
}
/**
* Advance the front-end state machine by 1 tick (25fps = 40ms per tick).
*/
@ -599,6 +754,10 @@ export function stepFlowTick(state: BnetFlowState): void {
state.globalTick += 1
state.stageTicks += 1
for (const token of HERO_TOKENS) {
stepHeroAvatarAnim(state.avatarAnims[token])
}
if (state.stage === 'bnet_connecting') {
// After 18 ticks (~720ms), connecting modal completes and transitions to Battle.net Login
if (state.stageTicks >= 18) {
@ -613,12 +772,20 @@ export function stepFlowTick(state: BnetFlowState): void {
for (const id of CAMPFIRE_CLASS_ORDER) {
const slot = state.campfireClasses[id]
const count = Math.max(1, slot.frameCount[slot.phase] || 1)
const rate = Math.max(1, slot.frameRate[slot.phase] || 1)
slot.phaseTicks += 1
if (slot.phaseTicks < rate) {
continue
}
slot.phaseTicks = 0
if (slot.phase === 'fw') {
if (slot.frameIndex + 1 < count) {
slot.frameIndex += 1
} else {
slot.phase = 'nu3'
slot.frameIndex = 0
slot.phaseTicks = 0
}
} else if (slot.phase === 'bw') {
if (slot.frameIndex + 1 < count) {
@ -626,9 +793,12 @@ export function stepFlowTick(state: BnetFlowState): void {
} else {
slot.phase = state.hoveredClass === id ? 'nu2' : 'nu1'
slot.frameIndex = 0
slot.phaseTicks = 0
}
} else {
slot.frameIndex = (slot.frameIndex + 1) % count
// Looping phases (nu1, nu2, nu3) wrap at framesPerDir - 1 per D2Win_AnimImage_Draw (0x6f8f0ac6)
const loopCount = Math.max(1, count - 1)
slot.frameIndex = (slot.frameIndex + 1) % loopCount
}
}
return
@ -641,10 +811,61 @@ export function stepFlowTick(state: BnetFlowState): void {
if (state.doorFrame >= 9 && state.stageTicks >= 30 && state.sceneReady) {
state.stage = 'in_game'
state.stageTicks = 0
state.tickAccumulatorMs = 0
state.lastFrameTimeMs = null
}
}
}
const TICK_EPSILON_MS = 1e-6
/**
* Advance the front-end state machine by an elapsed frame delta `dtMs` using a fixed
* 40ms (25fps) accumulator and a bounded catch-up clamp (`FRONTEND_MAX_FRAME_DELTA_MS = 200ms`).
* Returns the number of 25fps ticks executed during this frame.
*/
export function stepFlowByDeltaMs(state: BnetFlowState, dtMs: number): number {
if (!Number.isFinite(dtMs) || dtMs <= 0) {
return 0
}
const clampedDelta = Math.min(dtMs, FRONTEND_MAX_FRAME_DELTA_MS)
state.tickAccumulatorMs = Math.min(
state.tickAccumulatorMs + clampedDelta,
FRONTEND_MAX_FRAME_DELTA_MS,
)
let steps = 0
while (
state.tickAccumulatorMs + TICK_EPSILON_MS >= FRONTEND_TICK_MS &&
steps < FRONTEND_MAX_CATCHUP_TICKS
) {
state.tickAccumulatorMs = Math.max(0, state.tickAccumulatorMs - FRONTEND_TICK_MS)
stepFlowTick(state)
steps += 1
}
return steps
}
/**
* Advance the front-end state machine from a `requestAnimationFrame` timestamp `nowMs` (in ms).
* On the first frame (`state.lastFrameTimeMs === null`), anchors `lastFrameTimeMs = nowMs` without
* advancing a tick; subsequent frames compute `nowMs - state.lastFrameTimeMs` and step fixed 40ms ticks.
*/
export function stepFlowFrame(state: BnetFlowState, nowMs: number): number {
if (!Number.isFinite(nowMs)) {
return 0
}
if (state.lastFrameTimeMs === null) {
state.lastFrameTimeMs = nowMs
return 0
}
const dtMs = nowMs - state.lastFrameTimeMs
if (dtMs <= 0) {
return 0
}
state.lastFrameTimeMs = nowMs
return stepFlowByDeltaMs(state, dtMs)
}
export const CHAR_CLASS_ID_TO_CAMPFIRE: Readonly<Record<number, CampfireClassId>> = {
0: 'am',
1: 'so',
@ -732,6 +953,7 @@ export function applyOnlineCharactersToFlowState(
state.stage = 'char_select'
state.stageTicks = 0
state.activeInput = null
resetHeroAvatarAnims(state)
}
}
@ -745,6 +967,8 @@ export function applyOnlineCharactersToFlowState(
export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: string): void {
state.stage = 'bnet_login'
state.stageTicks = 0
state.tickAccumulatorMs = 0
state.lastFrameTimeMs = null
state.onlineConnected = false
state.loginError = reason
state.lastError = reason
@ -759,4 +983,5 @@ export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: stri
state.doorFrame = 0
state.sceneReady = false
state.activeInput = 'accountPassword'
resetHeroAvatarAnims(state)
}

View File

@ -15,13 +15,16 @@ import { CursorController } from '../ui/cursor.ts'
import type { FontAtlasMeta } from '../ui/font.ts'
import {
CAMPFIRE_CLASS_ORDER,
HERO_TOKENS,
canSubmitCreateGame,
cancelConnecting,
clickBattleNetButton,
confirmCharacterSelect,
enterCharCreate,
getAnimImageLoopFrame,
hoverCampfireClass,
isDifficultyUnlocked,
resetHeroAvatarAnims,
selectCampfireClass,
submitBattleNetLogin,
submitCharacterCreate,
@ -47,6 +50,8 @@ export interface PackedAnimStripMeta {
readonly width: number
readonly height: number
readonly frameCount: number
readonly rate?: number
readonly animRate?: number
readonly frames: readonly PackedAnimFrame[]
}
@ -256,16 +261,41 @@ export class FrontendRenderer {
this.manifest = (await manifestRes.json()) as FrontendManifest
this.fontMetas = (await fontRes.json()) as Record<string, FontAtlasMeta>
// Synchronize frame counts from manifest into flow state
// Synchronize frame counts and playback rates from manifest into flow state
for (const id of CAMPFIRE_CLASS_ORDER) {
const clsStrips = this.manifest.campfireClasses[id]
if (!clsStrips) continue
const slot = this.state.campfireClasses[id]
for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) {
if (clsStrips[phase]) {
slot.frameCount[phase] = clsStrips[phase]!.frameCount
const strip = clsStrips[phase]
if (strip) {
slot.frameCount[phase] = strip.frameCount
if (typeof strip.rate === 'number' && strip.rate > 0) {
slot.frameRate[phase] = strip.rate
}
}
}
const activeCount = Math.max(1, slot.frameCount[slot.phase] || 1)
const activeBound =
slot.phase === 'fw' || slot.phase === 'bw' ? activeCount : Math.max(1, activeCount - 1)
if (slot.frameIndex >= activeBound) {
slot.frameIndex = 0
slot.phaseTicks = 0
}
}
for (const token of HERO_TOKENS) {
const avatarStrip = this.manifest.charAvatars[token]
if (!avatarStrip) continue
const anim = this.state.avatarAnims[token]
anim.frameCount = avatarStrip.frameCount
if (typeof avatarStrip.animRate === 'number' && avatarStrip.animRate > 0) {
anim.animRate = avatarStrip.animRate
}
if (anim.frameIndex >= Math.max(1, anim.frameCount - 1)) {
anim.acc = 0
anim.frameIndex = 0
}
}
const promises: Promise<void>[] = []
@ -780,6 +810,7 @@ export class FrontendRenderer {
this.state.stage = 'char_select'
this.state.stageTicks = 0
this.state.activeInput = null
resetHeroAvatarAnims(this.state)
} else {
this.state.stage = 'bnet_login'
this.state.stageTicks = 0
@ -860,6 +891,7 @@ export class FrontendRenderer {
0,
Math.min(this.state.selectedSlotIndex, this.state.roster.length - 1),
)
resetHeroAvatarAnims(this.state)
}
}
},
@ -1036,6 +1068,7 @@ export class FrontendRenderer {
this.state.stage = 'char_select'
this.state.stageTicks = 0
this.state.activeInput = null
resetHeroAvatarAnims(this.state)
},
},
// Create / Join Game sub-panel buttons (`CancelButtonBlank.dc6` 96x32 @ (433,401) & `GameButtonBlank.dc6` 172x32 @ (594,401))
@ -1169,8 +1202,16 @@ export class FrontendRenderer {
private drawBurningMainLogo(): void {
if (!this.manifest) return
const tick = this.state.globalTick
this.drawAnimStripFrame(this.manifest.animStrips['logoMainBlack'], tick, 400, 120, false)
this.drawAnimStripFrame(this.manifest.animStrips['logoMainFire'], tick, 400, 120, true)
const blackStrip = this.manifest.animStrips['logoMainBlack']
const fireStrip = this.manifest.animStrips['logoMainFire']
if (blackStrip) {
const frameIdx = getAnimImageLoopFrame(tick, blackStrip.frameCount, blackStrip.rate ?? 1)
this.drawAnimStripFrame(blackStrip, frameIdx, 400, 120, false)
}
if (fireStrip) {
const frameIdx = getAnimImageLoopFrame(tick, fireStrip.frameCount, fireStrip.rate ?? 1)
this.drawAnimStripFrame(fireStrip, frameIdx, 400, 120, true)
}
}
private renderMainMenu(showConnectingModal: boolean): void {
@ -1314,14 +1355,16 @@ export class FrontendRenderer {
this.drawCampfireClass(id)
}
// Campfire flame (`fire.dc6`, control anchor (345, 454) minus f0.offset (-34, 132) -> (379, 322))
this.drawAnimStripFrame(
this.manifest!.animStrips['campfireFlame'],
this.state.globalTick,
379,
322,
true,
)
// Campfire flame (`fire.dc6`, control anchor (345, 454) minus f0.offset (-34, 132) -> (379, 322), rate=1, wraps at framesPerDir - 1)
const flameStrip = this.manifest!.animStrips['campfireFlame']
if (flameStrip) {
const flameFrame = getAnimImageLoopFrame(
this.state.globalTick,
flameStrip.frameCount,
flameStrip.rate ?? 1,
)
this.drawAnimStripFrame(flameStrip, flameFrame, 379, 322, true)
}
for (const id of frontClasses) {
this.drawCampfireClass(id)
@ -1461,8 +1504,10 @@ export class FrontendRenderer {
}
// Draw animated Level 1 unequipped Town-Neutral (TN) avatar standing on left side of slot
// Per D2Win.dll (0x6f8ee81b..0x6f8ee850), advances via 8.8 fixed-point COF animRate accumulator
const avatarStrip = this.manifest!.charAvatars[char.heroToken]
this.drawAnimStripFrame(avatarStrip, this.state.globalTick, slotX + 36, slotY + 82, false)
const avatarFrame = this.state.avatarAnims[char.heroToken]?.frameIndex ?? 0
this.drawAnimStripFrame(avatarStrip, avatarFrame, slotX + 36, slotY + 82, false)
// Right side metadata: Name (gold or red if Hardcore), Level & Class, Expansion Character, Expires in 90 days
const textX = slotX + 78
@ -1503,21 +1548,17 @@ export class FrontendRenderer {
const L = s.lobby
// Top-left & Top-right small burning D2 logos (`D2logoBlackBnet.dc6` + `D2logoBnet.dc6` at (84, 52) & (726, 52) per D2Multi.dll 0x6f9ea1b0 & 0x6f9ea240)
const bnetBlackStrip = this.manifest!.animStrips['logoBnetBlack']
const bnetFireStrip = this.manifest!.animStrips['logoBnetFire']
const bnetBlackFrame = bnetBlackStrip
? getAnimImageLoopFrame(this.state.globalTick, bnetBlackStrip.frameCount, bnetBlackStrip.rate ?? 1)
: 0
const bnetFireFrame = bnetFireStrip
? getAnimImageLoopFrame(this.state.globalTick, bnetFireStrip.frameCount, bnetFireStrip.rate ?? 1)
: 0
for (const logoX of [84, 726]) {
this.drawAnimStripFrame(
this.manifest!.animStrips['logoBnetBlack'],
this.state.globalTick,
logoX,
52,
false,
)
this.drawAnimStripFrame(
this.manifest!.animStrips['logoBnetFire'],
this.state.globalTick,
logoX,
52,
true,
)
this.drawAnimStripFrame(bnetBlackStrip, bnetBlackFrame, logoX, 52, false)
this.drawAnimStripFrame(bnetFireStrip, bnetFireFrame, logoX, 52, true)
}
// Left Chat Window messages (`x = 28..382, y = 112..408` per D2Multi.dll 0x6f9ea120)
@ -1689,7 +1730,8 @@ export class FrontendRenderer {
const selectedChar = this.state.roster[this.state.selectedSlotIndex]
if (selectedChar) {
const avatarStrip = this.manifest!.charAvatars[selectedChar.heroToken]
this.drawAnimStripFrame(avatarStrip, this.state.globalTick, 64, 578, false)
const avatarFrame = this.state.avatarAnims[selectedChar.heroToken]?.frameIndex ?? 0
this.drawAnimStripFrame(avatarStrip, avatarFrame, 64, 578, false)
this.drawBitmapText(selectedChar.name, 134, 528, {
font: 'font16',
color: selectedChar.isHardcore ? 1 : 4,
@ -1944,21 +1986,28 @@ export class FrontendRenderer {
cancelConnecting(this.state)
} else if (this.state.stage === 'bnet_login') {
this.state.stage = 'main_menu'
this.state.stageTicks = 0
this.state.activeInput = null
} else if (this.state.stage === 'char_create') {
if (this.state.roster.length > 0) {
this.state.stage = 'char_select'
this.state.stageTicks = 0
this.state.activeInput = null
resetHeroAvatarAnims(this.state)
} else {
this.state.stage = 'bnet_login'
this.state.stageTicks = 0
this.state.activeInput = 'accountName'
}
} else if (this.state.stage === 'char_select') {
this.state.stage = 'main_menu'
this.state.stageTicks = 0
this.state.activeInput = null
} else if (this.state.stage === 'bnet_lobby') {
this.state.stage = 'char_select'
this.state.stageTicks = 0
this.state.activeInput = null
resetHeroAvatarAnims(this.state)
}
return
}

View File

@ -16,8 +16,9 @@ import {
applyOnlineCharactersToFlowState,
createInitialFlowState,
isDifficultyUnlocked,
resetHeroAvatarAnims,
returnToLoginAfterSessionLoss,
stepFlowTick,
stepFlowFrame,
type BnetFlowState,
} from './frontend/flow-state.ts'
import { FrontendRenderer } from './frontend/frontend-renderer.ts'
@ -452,6 +453,11 @@ async function bootPlayApp(): Promise<PlayAppController> {
flowState.lobbySubPanel = 'create'
flowState.createGameError = null
flowState.activeInput = 'gameName'
flowState.stageTicks = 0
flowState.doorFrame = 0
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
resetHeroAvatarAnims(flowState)
toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs })
syncVisibility()
frontendRenderer.render(performance.now())
@ -573,6 +579,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
try {
await session.selectCharacter(selected.name)
st.stage = 'bnet_lobby'
st.stageTicks = 0
resetHeroAvatarAnims(st)
st.lobbySubPanel = 'create'
st.createGameError = null
if (!isDifficultyUnlocked(selected, st.difficulty)) {
@ -647,6 +655,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
st.stage = 'door_loading'
st.stageTicks = 0
st.doorFrame = 0
st.sceneReady = false
st.createGameError = null
st.lastError = null
st.statusText = ''
@ -665,11 +674,20 @@ async function bootPlayApp(): Promise<PlayAppController> {
})
st.sceneReady = true
st.stage = 'in_game'
st.stageTicks = 0
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
inGameView = true
syncVisibility()
} catch (err) {
if (err instanceof SessionLostError) return
st.stage = 'bnet_lobby'
st.stageTicks = 0
st.doorFrame = 0
st.sceneReady = false
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
resetHeroAvatarAnims(st)
st.createGameError = err instanceof Error ? err.message : String(err)
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
@ -679,6 +697,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
st.stage = 'door_loading'
st.stageTicks = 0
st.doorFrame = 0
st.sceneReady = false
st.createGameError = null
st.lastError = null
st.statusText = ''
@ -691,11 +710,20 @@ async function bootPlayApp(): Promise<PlayAppController> {
})
st.sceneReady = true
st.stage = 'in_game'
st.stageTicks = 0
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
inGameView = true
syncVisibility()
} catch (err) {
if (err instanceof SessionLostError) return
st.stage = 'bnet_lobby'
st.stageTicks = 0
st.doorFrame = 0
st.sceneReady = false
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
resetHeroAvatarAnims(st)
st.createGameError = err instanceof Error ? err.message : String(err)
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
@ -725,6 +753,9 @@ async function bootPlayApp(): Promise<PlayAppController> {
inGameView = true
flowState.sceneReady = true
flowState.stage = 'in_game'
flowState.stageTicks = 0
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
toolbar.setConnectionState({ phase: 'd2gs', rttMs: snap.pingMs })
} else if (snap.phase === 'lobby') {
if (inGameView || flowState.stage === 'in_game') {
@ -853,14 +884,19 @@ async function bootPlayApp(): Promise<PlayAppController> {
lastFrameMs = nowMs
if (!inGameView) {
stepFlowTick(flowState)
stepFlowFrame(flowState, nowMs)
if (flowState.stage === 'in_game' && (session.phase === 'ingame' || session.phase === 'replay')) {
inGameView = true
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
syncVisibility()
} else {
frontendRenderer.render(nowMs)
return
}
} else {
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
}
session.tick(dtMs || 40, nowMs)

View File

@ -41,6 +41,8 @@ export interface CharacterSheet {
readonly framesPerDirection: number
/** Layers the COF declared. */
readonly layers: number
/** Animation speed byte from the COF header (8.8 fixed-point, 256 = 25fps). */
readonly speed?: number
/** Layer/direction combinations skipped because a sprite was missing. */
readonly skipped: number
/** The DCC members that were decoded, for provenance. */
@ -226,6 +228,7 @@ export async function loadCharacterSheet(
directions: cof.numberOfDirections,
framesPerDirection: cof.framesPerDirection,
layers: cof.layers.length,
speed: cof.speed,
skipped,
members,
notes,

View File

@ -1,6 +1,13 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
CAMPFIRE_ANIM_RATES,
CAMPFIRE_CLASS_ORDER,
FRONTEND_MAX_CATCHUP_TICKS,
FRONTEND_TICK_MS,
HERO_TN_ANIM_SPECS,
HERO_TOKENS,
canSubmitCreateGame,
cancelConnecting,
charSummaryToRosterCharacter,
@ -8,18 +15,22 @@ import {
confirmCharacterSelect,
createInitialFlowState,
enterCharCreate,
getAnimImageLoopFrame,
getCharacterMaxDifficulty,
hoverCampfireClass,
isDifficultyUnlocked,
isValidCharacterName,
returnToLoginAfterSessionLoss,
selectCampfireClass,
stepFlowByDeltaMs,
stepFlowFrame,
stepFlowTick,
submitBattleNetLogin,
submitCharacterCreate,
submitCreateGame,
toggleChatGem,
} from '../../src/client/frontend/flow-state.ts'
import { FrontendRenderer } from '../../src/client/frontend/frontend-renderer.ts'
import { FrontendRenderer, type FrontendManifest } from '../../src/client/frontend/frontend-renderer.ts'
import { parseMcpCharStatString } from '../../src/netproto/mcp/packets.ts'
describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
@ -292,4 +303,503 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(created?.heroToken).toBe('ai')
expect(created?.className).toBe('Assassin')
})
it('decouples front-end tick progression from display refresh rate (25 ticks/sec across 60Hz, 120Hz, 144Hz) and clamps large frame deltas', () => {
expect(FRONTEND_TICK_MS).toBe(40)
expect(FRONTEND_MAX_CATCHUP_TICKS).toBe(5)
for (const refreshHz of [60, 120, 144]) {
const state = createInitialFlowState()
// First frame anchors lastFrameTimeMs at t = 0ms
expect(stepFlowFrame(state, 0)).toBe(0)
expect(state.globalTick).toBe(0)
for (let i = 1; i <= refreshHz; i++) {
stepFlowFrame(state, (i * 1000) / refreshHz)
}
expect(state.globalTick).toBe(25)
expect(state.stageTicks).toBe(25)
}
// Also verify delta-based stepping across 60Hz, 120Hz, 144Hz, and 240Hz
for (const refreshHz of [60, 120, 144, 240]) {
const state = createInitialFlowState()
const dtMs = 1000 / refreshHz
for (let i = 0; i < refreshHz; i++) {
stepFlowByDeltaMs(state, dtMs)
}
expect(state.globalTick).toBe(25)
}
// Verify bounded catch-up clamp (e.g. 5000ms tab switch clamps to 5 ticks = 200ms)
const pausedState = createInitialFlowState()
expect(stepFlowFrame(pausedState, 100)).toBe(0)
const catchupSteps = stepFlowFrame(pausedState, 5100)
expect(catchupSteps).toBe(FRONTEND_MAX_CATCHUP_TICKS)
expect(pausedState.globalTick).toBe(FRONTEND_MAX_CATCHUP_TICKS)
expect(pausedState.tickAccumulatorMs).toBe(0)
// Non-positive or non-finite timestamps/deltas must be true no-ops that do not rewind lastFrameTimeMs
expect(stepFlowFrame(pausedState, 5050)).toBe(0)
expect(pausedState.lastFrameTimeMs).toBe(5100)
expect(stepFlowFrame(pausedState, 5116)).toBe(0)
expect(pausedState.tickAccumulatorMs).toBe(16)
expect(stepFlowFrame(pausedState, Number.NaN)).toBe(0)
expect(stepFlowByDeltaMs(pausedState, -16)).toBe(0)
expect(stepFlowByDeltaMs(pausedState, Number.NaN)).toBe(0)
expect(pausedState.globalTick).toBe(FRONTEND_MAX_CATCHUP_TICKS)
})
it('wraps AnimImage looping controls (burning logos & campfire flame) at framesPerDir - 1 with rate = 1 per D2Win.dll (0x6f8f0ac6)', () => {
// 30-frame DC6 animations (D2logoBlackLeft/Right, D2logoFireLeft/Right, D2logoBlackBnet, D2logoBnet, fire.dc6)
// loop over frames 0..28 (29 frames) and wrap to 0 at tick 29
for (let t = 0; t <= 28; t++) {
expect(getAnimImageLoopFrame(t, 30, 1)).toBe(t)
}
expect(getAnimImageLoopFrame(29, 30, 1)).toBe(0)
expect(getAnimImageLoopFrame(30, 30, 1)).toBe(1)
expect(getAnimImageLoopFrame(57, 30, 1)).toBe(28)
expect(getAnimImageLoopFrame(58, 30, 1)).toBe(0)
})
it('advances 7-class campfire animations at exact 1.13c D2Launch.dll (0x6fa60c08) per-class, per-phase rates and wraps looping phases at framesPerDir - 1', () => {
// Verify table against 1.13c D2Launch.dll @ 0x6fa60c08
expect(CAMPFIRE_ANIM_RATES).toEqual({
as: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
dz: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
am: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
ne: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
ba: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
so: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
pa: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
})
const state = createInitialFlowState()
enterCharCreate(state)
// 1. Back idle ('nu1'):
// - Amazon ('am'): rate = 3 (120ms/frame), frameCount = 26 -> loops 0..24 (25 frames, wraps at tick 75)
// - Barbarian ('ba'): rate = 2 (80ms/frame), frameCount = 16 -> loops 0..14 (15 frames, wraps at tick 30)
stepFlowTick(state) // tick 1
expect(state.campfireClasses.am.frameIndex).toBe(0)
expect(state.campfireClasses.ba.frameIndex).toBe(0)
stepFlowTick(state) // tick 2
expect(state.campfireClasses.am.frameIndex).toBe(0)
expect(state.campfireClasses.ba.frameIndex).toBe(1)
stepFlowTick(state) // tick 3
expect(state.campfireClasses.am.frameIndex).toBe(1)
expect(state.campfireClasses.ba.frameIndex).toBe(1)
// Step to tick 28 -> Barbarian at frame 14 (last frame before wrap at framesPerDir - 1 = 15)
for (let t = 4; t <= 28; t++) {
stepFlowTick(state)
}
expect(state.campfireClasses.ba.frameIndex).toBe(14)
stepFlowTick(state) // tick 29
expect(state.campfireClasses.ba.frameIndex).toBe(14)
stepFlowTick(state) // tick 30 -> wraps to 0 (never reaches frame 15)
expect(state.campfireClasses.ba.frameIndex).toBe(0)
// Step to tick 72 -> Amazon at frame 24 (last frame before wrap at framesPerDir - 1 = 25)
for (let t = 31; t <= 72; t++) {
stepFlowTick(state)
}
expect(state.campfireClasses.am.frameIndex).toBe(24)
stepFlowTick(state) // tick 73
stepFlowTick(state) // tick 74
expect(state.campfireClasses.am.frameIndex).toBe(24)
stepFlowTick(state) // tick 75 -> wraps to 0 (never reaches frame 25)
expect(state.campfireClasses.am.frameIndex).toBe(0)
// 2. Hover ('nu2') and Select ('fw' -> 'nu3'):
hoverCampfireClass(state, 'as')
expect(state.campfireClasses.as.phase).toBe('nu2')
expect(state.campfireClasses.as.frameIndex).toBe(0)
// Assassin nu2 has rate = 3
stepFlowTick(state)
stepFlowTick(state)
expect(state.campfireClasses.as.frameIndex).toBe(0)
stepFlowTick(state)
expect(state.campfireClasses.as.frameIndex).toBe(1)
// Select Paladin ('pa'): fw has rate = 1 (28 frames), then nu3 has rate = 2 (13 frames -> wraps at 12)
selectCampfireClass(state, 'pa')
expect(state.campfireClasses.pa.phase).toBe('fw')
for (let i = 0; i < 28; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.pa.phase).toBe('nu3')
expect(state.campfireClasses.pa.frameIndex).toBe(0)
// Paladin nu3 advances 1 frame every 2 ticks and wraps at 13 - 1 = 12
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(0)
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(1)
for (let i = 0; i < 20; i++) {
stepFlowTick(state)
}
expect(state.campfireClasses.pa.frameIndex).toBe(11)
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(11)
stepFlowTick(state)
expect(state.campfireClasses.pa.frameIndex).toBe(0)
})
it('advances Character Selection & Lobby TN HTH hero avatars via 1.13c D2Win.dll (0x6f8ee81b) 8.8 fixed-point COF animRate accumulator', () => {
expect(HERO_TN_ANIM_SPECS).toEqual({
am: { frameCount: 16, animRate: 80 },
so: { frameCount: 16, animRate: 80 },
ne: { frameCount: 16, animRate: 80 },
pa: { frameCount: 16, animRate: 128 },
ba: { frameCount: 16, animRate: 80 },
dz: { frameCount: 10, animRate: 96 },
ai: { frameCount: 12, animRate: 64 },
})
const state = createInitialFlowState()
for (const token of HERO_TOKENS) {
expect(state.avatarAnims[token].acc).toBe(0)
expect(state.avatarAnims[token].frameIndex).toBe(0)
}
// Tick 1:
// am: acc=80 -> frame 0; pa: acc=128 -> frame 0; dz: acc=96 -> frame 0; ai: acc=64 -> frame 0
stepFlowTick(state)
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
expect(state.avatarAnims.dz.frameIndex).toBe(0)
expect(state.avatarAnims.ai.frameIndex).toBe(0)
// Tick 2:
// pa: acc=256 -> frame 1; others still at frame 0
stepFlowTick(state)
expect(state.avatarAnims.pa.frameIndex).toBe(1)
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.dz.frameIndex).toBe(0)
expect(state.avatarAnims.ai.frameIndex).toBe(0)
// Tick 3:
// dz: acc=288 -> frame 1; am: acc=240 -> frame 0; ai: acc=192 -> frame 0
stepFlowTick(state)
expect(state.avatarAnims.dz.frameIndex).toBe(1)
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.ai.frameIndex).toBe(0)
// Tick 4:
// am: acc=320 -> frame 1; ai: acc=256 -> frame 1; pa: acc=512 -> frame 2
stepFlowTick(state)
expect(state.avatarAnims.am.frameIndex).toBe(1)
expect(state.avatarAnims.so.frameIndex).toBe(1)
expect(state.avatarAnims.ne.frameIndex).toBe(1)
expect(state.avatarAnims.ba.frameIndex).toBe(1)
expect(state.avatarAnims.ai.frameIndex).toBe(1)
expect(state.avatarAnims.pa.frameIndex).toBe(2)
// Advance to tick 24 -> Druid (dz: frameCount=10, wrapFrame=9, animRate=96) reaches 24*96 = 2304 (2304>>8 = 9 >= 9) -> wraps to 0
for (let t = 5; t <= 23; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.dz.frameIndex).toBe(8)
stepFlowTick(state) // tick 24
expect(state.avatarAnims.dz.frameIndex).toBe(0)
expect(state.avatarAnims.dz.acc).toBe(0)
// Advance to tick 30 -> Paladin (pa: frameCount=16, wrapFrame=15, animRate=128) reaches 30*128 = 3840 (3840>>8 = 15 >= 15) -> wraps to 0
for (let t = 25; t <= 29; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.pa.frameIndex).toBe(14)
stepFlowTick(state) // tick 30
expect(state.avatarAnims.pa.frameIndex).toBe(0)
expect(state.avatarAnims.pa.acc).toBe(0)
// Advance to tick 44 -> Assassin (ai: frameCount=12, wrapFrame=11, animRate=64) reaches 44*64 = 2816 (2816>>8 = 11 >= 11) -> wraps to 0
for (let t = 31; t <= 43; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.ai.frameIndex).toBe(10)
stepFlowTick(state) // tick 44
expect(state.avatarAnims.ai.frameIndex).toBe(0)
expect(state.avatarAnims.ai.acc).toBe(0)
// Advance to tick 48 -> Amazon/Sorceress/Necromancer/Barbarian (frameCount=16, wrapFrame=15, animRate=80) reach 48*80 = 3840 (3840>>8 = 15 >= 15) -> wrap to 0
for (let t = 45; t <= 47; t++) {
stepFlowTick(state)
}
expect(state.avatarAnims.am.frameIndex).toBe(14)
stepFlowTick(state) // tick 48
expect(state.avatarAnims.am.frameIndex).toBe(0)
expect(state.avatarAnims.so.frameIndex).toBe(0)
expect(state.avatarAnims.ne.frameIndex).toBe(0)
expect(state.avatarAnims.ba.frameIndex).toBe(0)
})
it('keeps public/ui/frontend/manifest.json and FrontendRenderer aligned on 1.13c rates and wrapped frame indices', () => {
const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest
// 1. Verify manifest animStrips rates
expect(manifest.animStrips.logoMainBlack?.rate).toBe(1)
expect(manifest.animStrips.logoMainFire?.rate).toBe(1)
expect(manifest.animStrips.logoBnetBlack?.rate).toBe(1)
expect(manifest.animStrips.logoBnetFire?.rate).toBe(1)
expect(manifest.animStrips.campfireFlame?.rate).toBe(1)
expect(manifest.animStrips.loadingDoor?.rate).toBe(3)
// 2. Verify manifest campfireClasses rates
for (const clsId of CAMPFIRE_CLASS_ORDER) {
for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) {
expect(manifest.campfireClasses[clsId]?.[phase]?.rate).toBe(
CAMPFIRE_ANIM_RATES[clsId][phase],
)
}
}
// 3. Verify manifest charAvatars animRates & frameCounts
for (const token of HERO_TOKENS) {
expect(manifest.charAvatars[token]?.frameCount).toBe(HERO_TN_ANIM_SPECS[token].frameCount)
expect(manifest.charAvatars[token]?.animRate).toBe(HERO_TN_ANIM_SPECS[token].animRate)
}
// 4. Verify FrontendRenderer draws wrapped logo/flame frames and COF-accumulated avatar frames
const state = createInitialFlowState()
const drawCalls: { img: unknown; sx: number; sy: number; dx: number; dy: number }[] = []
const fakeCtx = {
imageSmoothingEnabled: false,
clearRect: () => {},
save: () => {},
restore: () => {},
fillRect: () => {},
drawImage: (
img: unknown,
sx: number,
sy: number,
_sw?: number,
_sh?: number,
dx?: number,
dy?: number,
) => {
drawCalls.push({ img, sx, sy, dx: dx ?? sx, dy: dy ?? sy })
},
}
const fakeCanvas = {
getContext: () => fakeCtx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest
renderer.ready = true
for (const strip of Object.values(manifest.animStrips)) {
renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement)
}
for (const strip of Object.values(manifest.charAvatars)) {
renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement)
}
// At globalTick = 29, 30-frame main menu logo wraps to frame 0 (not frame 29)
state.stage = 'main_menu'
state.globalTick = 29
drawCalls.length = 0
renderer.render(1000)
const logoCall = drawCalls.find(
(c) => (c.img as { id?: string })?.id === 'logo-main-black.png',
)
expect(logoCall).toBeDefined()
const logoFrame0 = manifest.animStrips.logoMainBlack!.frames[0]!
expect(logoCall?.sx).toBe(logoFrame0.x)
expect(logoCall?.sy).toBe(logoFrame0.y)
// In char_select after 2 ticks, Sorceress (animRate=80) is still on frame 0 (160>>8 = 0), not frame 2
state.roster = [
{
id: 'so-1',
name: 'Isendra',
classId: 'so',
heroToken: 'so',
className: 'Sorceress',
level: 1,
isExpansion: true,
isHardcore: false,
isLadder: true,
progression: 0,
maxDifficulty: 'normal',
createdAt: 0,
},
]
state.stage = 'char_select'
state.globalTick = 0
state.avatarAnims.so.acc = 0
state.avatarAnims.so.frameIndex = 0
stepFlowTick(state)
stepFlowTick(state)
expect(state.globalTick).toBe(2)
expect(state.avatarAnims.so.frameIndex).toBe(0)
drawCalls.length = 0
renderer.render(1080)
const avatarCallAtTick2 = drawCalls.find(
(c) => (c.img as { id?: string })?.id === 'avatars/char-so-tn.png',
)
const soFrame0 = manifest.charAvatars.so!.frames[0]!
expect(avatarCallAtTick2?.sx).toBe(soFrame0.x)
expect(avatarCallAtTick2?.sy).toBe(soFrame0.y)
// After 2 more ticks (tick 4 -> acc = 320 -> 320>>8 = 1), Sorceress advances to frame 1
stepFlowTick(state)
stepFlowTick(state)
expect(state.avatarAnims.so.frameIndex).toBe(1)
drawCalls.length = 0
renderer.render(1160)
const avatarCallAtTick4 = drawCalls.find(
(c) => (c.img as { id?: string })?.id === 'avatars/char-so-tn.png',
)
const soFrame1 = manifest.charAvatars.so!.frames[1]!
expect(avatarCallAtTick4?.sx).toBe(soFrame1.x)
expect(avatarCallAtTick4?.sy).toBe(soFrame1.y)
})
it('resets lastFrameTimeMs and tickAccumulatorMs when entering in_game or returning after session loss to prevent catch-up bursts', () => {
const state = createInitialFlowState()
state.stage = 'door_loading'
state.doorFrame = 9
state.stageTicks = 29
state.sceneReady = true
// Anchor at 1000ms and advance 65ms (1 tick + 25ms leftover in tickAccumulatorMs)
expect(stepFlowFrame(state, 1000)).toBe(0)
expect(stepFlowFrame(state, 1065)).toBe(1)
expect(state.stage).toBe('in_game')
expect(state.tickAccumulatorMs).toBe(0)
expect(state.lastFrameTimeMs).toBeNull()
// Simulate a stale timestamp/accumulator right before a background session disconnect
state.lastFrameTimeMs = 2000
state.tickAccumulatorMs = 35
state.avatarAnims.so.acc = 512
state.avatarAnims.so.frameIndex = 2
returnToLoginAfterSessionLoss(state, 'Connection lost')
expect(state.stage).toBe('bnet_login')
expect(state.stageTicks).toBe(0)
expect(state.tickAccumulatorMs).toBe(0)
expect(state.lastFrameTimeMs).toBeNull()
expect(state.avatarAnims.so.acc).toBe(0)
expect(state.avatarAnims.so.frameIndex).toBe(0)
// First front-end frame after 30s background disconnect must anchor (0 ticks), not burst 5 catch-up ticks
const tickBefore = state.globalTick
expect(stepFlowFrame(state, 32000)).toBe(0)
expect(state.globalTick).toBe(tickBefore)
expect(stepFlowFrame(state, 32040)).toBe(1)
expect(state.globalTick).toBe(tickBefore + 1)
// Subsequent game creation after a prior in_game session must reset sceneReady = false
state.stage = 'bnet_lobby'
state.gameName = 'SecondRoom'
state.maxPlayers = '8'
state.sceneReady = true
expect(submitCreateGame(state)).toBe(true)
expect(state.stage).toBe('door_loading')
expect(state.sceneReady).toBe(false)
for (let i = 0; i < 35; i++) {
stepFlowTick(state)
}
expect(state.doorFrame).toBe(9)
expect(state.stage).toBe('door_loading')
})
it('resets stageTicks and avatarAnims across FrontendRenderer Escape key transitions and offline character deletion', () => {
const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest
const state = createInitialFlowState()
const fakeCtx = {
imageSmoothingEnabled: false,
clearRect: () => {},
save: () => {},
restore: () => {},
fillRect: () => {},
drawImage: () => {},
}
const fakeCanvas = {
getContext: () => fakeCtx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const renderer = new FrontendRenderer(fakeCanvas, state)
renderer.manifest = manifest
renderer.ready = true
const makeEscapeEvent = (): KeyboardEvent =>
({
key: 'Escape',
preventDefault: () => {},
}) as unknown as KeyboardEvent
// 1. Escape on bnet_login -> main_menu resets stageTicks
state.stage = 'bnet_login'
state.stageTicks = 12
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('main_menu')
expect(state.stageTicks).toBe(0)
// 2. Escape on char_create (empty roster) -> bnet_login resets stageTicks
state.roster = []
enterCharCreate(state)
stepFlowTick(state)
stepFlowTick(state)
expect(state.stageTicks).toBe(2)
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('bnet_login')
expect(state.stageTicks).toBe(0)
// 3. Escape on char_create (non-empty roster) -> char_select resets stageTicks and avatarAnims
enterCharCreate(state)
selectCampfireClass(state, 'pa')
state.charName = 'Roland'
submitCharacterCreate(state)
enterCharCreate(state)
for (let i = 0; i < 6; i++) stepFlowTick(state)
expect(state.stageTicks).toBe(6)
expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0)
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('char_select')
expect(state.stageTicks).toBe(0)
expect(state.avatarAnims.pa.acc).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
// 4. Escape on bnet_lobby -> char_select resets stageTicks and avatarAnims
confirmCharacterSelect(state)
for (let i = 0; i < 6; i++) stepFlowTick(state)
expect(state.stage).toBe('bnet_lobby')
expect(state.stageTicks).toBe(6)
expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0)
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('char_select')
expect(state.stageTicks).toBe(0)
expect(state.avatarAnims.pa.acc).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
// 5. Offline char_select_delete button resets avatarAnims
for (let i = 0; i < 6; i++) stepFlowTick(state)
expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0)
const deleteBtn = renderer.getActiveButtons().find((b) => b.id === 'char_select_delete')
expect(deleteBtn).toBeDefined()
deleteBtn!.onClick()
expect(state.roster).toHaveLength(0)
expect(state.avatarAnims.pa.acc).toBe(0)
expect(state.avatarAnims.pa.frameIndex).toBe(0)
// 6. Escape on char_select -> main_menu resets stageTicks
state.stageTicks = 9
renderer.handleKeyDown(makeEscapeEvent())
expect(state.stage).toBe('main_menu')
expect(state.stageTicks).toBe(0)
})
})