From 102d001de1ea0e87cb7f995b786d0c16f27018c8 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:02:56 +0000 Subject: [PATCH 1/3] fix(automap): sync minimap/automap active level with main map on area transitions (Fixes #549) --- src/client/automap/automap-view.ts | 105 ++++++-- src/client/input/command-mapper.ts | 17 +- src/client/scene/world-renderer.ts | 14 +- src/client/session/online-session.ts | 93 ++++++- src/client/ui/minimap.ts | 8 + tests/client/minimap-level-switch-549.test.ts | 244 ++++++++++++++++++ 6 files changed, 437 insertions(+), 44 deletions(-) create mode 100644 tests/client/minimap-level-switch-549.test.ts diff --git a/src/client/automap/automap-view.ts b/src/client/automap/automap-view.ts index 230aa59..50fe585 100644 --- a/src/client/automap/automap-view.ts +++ b/src/client/automap/automap-view.ts @@ -288,6 +288,25 @@ export class AutomapView { return this.levelViews.get(areaId)! } + /** + * Switch the active level (`currentAreaId` / `currentActId`) within the current game session + * without clearing `levelViews` or `serverRevealedRooms` (#549). + */ + syncActiveLevel(areaId: number, existingLevelView?: LevelView): LevelView { + if (areaId > 0) { + this.currentAreaId = areaId + this.currentActId = getActForArea(areaId) + } + const targetAreaId = this.currentAreaId + if (existingLevelView && existingLevelView.areaId === targetAreaId) { + this.levelViews.set(targetAreaId, existingLevelView) + } else if (!this.levelViews.has(targetAreaId)) { + this.ensureLevelView(targetAreaId) + } + this.applyRevealModeToViews() + return this.levelViews.get(targetAreaId)! + } + /** * Record a server `0x07 AddMapRoom` event. */ @@ -498,36 +517,62 @@ export class AutomapView { /** * Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`). */ - buildMinimapLevel(lang: TblLang = getTblLang()): MinimapLevel { + buildMinimapLevel(lang: TblLang = getTblLang(), areaId?: number): MinimapLevel { + if (areaId !== undefined && areaId > 0 && areaId !== this.currentAreaId) { + this.syncActiveLevel(areaId) + } const snap = this.buildSnapshot([], lang) const view = this.ensureLevelView(this.currentAreaId) const allRooms = view.rooms let minCellX = 0 let minCellY = 0 - let maxCellX = 40 - let maxCellY = 40 - if (allRooms.length > 0) { - minCellX = Math.min(...allRooms.map(r => r.tileX)) - minCellY = Math.min(...allRooms.map(r => r.tileY)) - maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) - maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) - } + let cellsX = 40 + let cellsY = 40 + let subW = 200 + let blocked: Uint8Array + let originX = 0 + let originY = 0 - const cellsX = Math.max(1, maxCellX - minCellX) - const cellsY = Math.max(1, maxCellY - minCellY) - const subW = cellsX * 5 - const subH = cellsY * 5 - const blocked = new Uint8Array(subW * subH) - - for (let sy = 0; sy < subH; sy++) { - for (let sx = 0; sx < subW; sx++) { - const absSubX = minCellX * 5 + sx - const absSubY = minCellY * 5 + sy - if (!isWalkableWorldSubTile(view, absSubX, absSubY)) { - blocked[sy * subW + sx] = 1 + if ( + view.cellsX > 0 && + view.cellsY > 0 && + view.blocked && + view.blocked.length === view.cellsX * 5 * view.cellsY * 5 + ) { + minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5) + minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5) + cellsX = view.cellsX + cellsY = view.cellsY + subW = view.gridWidth || cellsX * 5 + blocked = view.blocked + originX = view.isoOriginX ?? (minCellX - minCellY) * 80 + originY = view.isoOriginY ?? (minCellX + minCellY) * 40 + } else { + let maxCellX = 40 + let maxCellY = 40 + if (allRooms.length > 0) { + minCellX = Math.min(...allRooms.map(r => r.tileX)) + minCellY = Math.min(...allRooms.map(r => r.tileY)) + maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) + maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) + } + cellsX = Math.max(1, maxCellX - minCellX) + cellsY = Math.max(1, maxCellY - minCellY) + subW = cellsX * 5 + const subH = cellsY * 5 + blocked = new Uint8Array(subW * subH) + for (let sy = 0; sy < subH; sy++) { + for (let sx = 0; sx < subW; sx++) { + const absSubX = minCellX * 5 + sx + const absSubY = minCellY * 5 + sy + if (!isWalkableWorldSubTile(view, absSubX, absSubY)) { + blocked[sy * subW + sx] = 1 + } } } + originX = (minCellX - minCellY) * 80 + originY = (minCellX + minCellY) * 40 } const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({ @@ -538,6 +583,20 @@ export class AutomapView { targetName: wm.label, })) + if (Array.isArray(view.entrances)) { + for (const ent of view.entrances) { + if (ent.toLevelId > 0 && ent.toLevelId !== this.currentAreaId) { + markers.push({ + cellX: ent.x, + cellY: ent.y, + kind: 'exit', + toLevelId: ent.toLevelId, + targetName: levelDisplayName(ent.toLevelId, lang), + }) + } + } + } + return { key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`, cellsX, @@ -545,8 +604,8 @@ export class AutomapView { blocked, gridWidth: subW, markers, - originX: (minCellX - minCellY) * 80, - originY: (minCellX + minCellY) * 40, + originX, + originY, levelId: this.currentAreaId, levelName: levelDisplayName(this.currentAreaId, lang), ...(view.automap !== undefined ? { automap: view.automap } : {}), diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 8ef5dda..b021064 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -897,13 +897,22 @@ export class CommandMapper { return this.lastPath } + const snap = this.predictor.getSnapshot() + const sx = Math.round(snap.logicalX) + const sy = Math.round(snap.logicalY) const levelView = - this.mapService?.getActiveLevelView() ?? this.automapView?.getLevelView() ?? null + (this.mapService && this.world.areaId > 0 && (sx !== 0 || sy !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(sx, sy, [ + this.world.areaId, + ...this.world.getRevealedAreaIds(), + ]) + : null) ?? + (this.world.areaId > 0 ? this.automapView?.getLevelView(this.world.areaId) : undefined) ?? + this.mapService?.getActiveLevelView() ?? + this.automapView?.getLevelView() ?? + null if (levelView && levelView.walkable.length > 0) { - const snap = this.predictor.getSnapshot() - const sx = Math.round(snap.logicalX) - const sy = Math.round(snap.logicalY) const ox = levelView.subOriginX ?? 0 const oy = levelView.subOriginY ?? 0 const inBounds = diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..20b45b0 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -927,11 +927,19 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram ctx.minimap.visible = false } else { ctx.minimap.visible = true - const baseMinimapLevel = ctx.automapView.buildMinimapLevel() + if (levelView && levelView.areaId > 0) { + ctx.automapView.syncActiveLevel(levelView.areaId, levelView) + } else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) { + ctx.automapView.syncActiveLevel(snapshot.levelId) + } + const baseMinimapLevel = ctx.automapView.buildMinimapLevel( + undefined, + levelView?.areaId ?? snapshot.levelId, + ) const effectiveMinimapLevel: MinimapLevel = { ...baseMinimapLevel, - originX: levelView?.isoOriginX ?? 0, - originY: levelView?.isoOriginY ?? 0, + originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0, + originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0, preRevealTown: isTownLevel(snapshot.levelId), } const localSub = snapshot.playerLocalSubTile diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..abc30c7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -270,6 +270,7 @@ export class OnlineSession { private disconnectReasonValue: string | null = null private pingMsValue = 0 private tickCounter = 0 + private readonly knownAreaIds = new Set() private readonly outboundCommandsLog: ClientCommand[] = [] private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>() @@ -373,17 +374,11 @@ export class OnlineSession { // such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG // canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a // level exists the scene reports not-ready. + // #549: resolving the active levelView also keeps `world.areaId` and `automapView` in lockstep. const predSnap = this.predictor.getSnapshot() const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y - const levelView = - this.levelView ?? - (this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0) - ? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [ - this.world.areaId, - ...this.world.getRevealedAreaIds(), - ]) - : null) + const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0) return { tick: this.tickCounter, @@ -694,6 +689,7 @@ export class OnlineSession { const savedName = this.world.self.name const savedClass = this.world.self.charClass this.world.reset() + this.knownAreaIds.clear() this.world.self.name = savedName this.world.self.charClass = savedClass this.predictor.setPosition(0, 0) @@ -921,6 +917,66 @@ export class OnlineSession { } } + private getCandidateAreaIds(): number[] { + const out: number[] = [] + if (this.world.areaId > 0) { + out.push(this.world.areaId) + } + for (const id of this.world.getRevealedAreaIds()) { + if (id > 0 && !out.includes(id)) { + out.push(id) + } + } + for (const id of this.knownAreaIds) { + if (id > 0 && !out.includes(id)) { + out.push(id) + } + } + return out + } + + /** + * Resolve the `LevelView` containing the local player's current world sub-tile and synchronize + * `world.areaId` and `automapView` whenever the player crosses into a different area (#549). + */ + private syncActiveAreaFromPlayerPosition(options?: { + readonly emitHudSync?: boolean + }): LevelView | null { + const predSnap = this.predictor.getSnapshot() + const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x + const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y + const candidateIds = this.getCandidateAreaIds() + const levelView = + this.levelView ?? + (this.mapService && candidateIds.length > 0 && (playerSubX !== 0 || playerSubY !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, candidateIds) + : null) + + if (levelView && levelView.areaId > 0) { + if (this.world.areaId > 0) { + this.knownAreaIds.add(this.world.areaId) + } + this.knownAreaIds.add(levelView.areaId) + const areaChanged = this.world.areaId !== levelView.areaId + const automapOutOfSync = + this.automapView.getCurrentAreaId() !== levelView.areaId || + this.automapView.getLevelView(levelView.areaId) === undefined + if (areaChanged) { + this.world.areaId = levelView.areaId + } + if (areaChanged || automapOutOfSync) { + this.automapView.syncActiveLevel(levelView.areaId, levelView) + } + if (areaChanged) { + void this.soundService.onEnterArea(levelView.areaId) + if (options?.emitHudSync) { + this.hudModel.syncFromWorld() + } + } + } + return levelView + } + /** * `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a * town level, or `null` while no loaded level contains it. The sub-tile is the one the gate @@ -932,7 +988,7 @@ export class OnlineSession { const levelView = this.mapService.findLevelViewAtWorldSubTile( Math.round(snap.logicalX), Math.round(snap.logicalY), - [this.world.areaId, ...this.world.getRevealedAreaIds()], + this.getCandidateAreaIds(), ) return levelView === null ? null : isTownLevel(levelView.areaId) } @@ -1070,7 +1126,11 @@ export class OnlineSession { break } } - const activeView = this.mapService?.getActiveLevelView() ?? undefined + this.knownAreaIds.clear() + this.knownAreaIds.add(ev.areaId) + const activeView = + this.mapService?.getActiveLevelView() ?? + (this.levelView && this.levelView.areaId === ev.areaId ? this.levelView : undefined) this.automapView.setContext( ev.mapSeed, this.world.difficulty, @@ -1082,6 +1142,9 @@ export class OnlineSession { } case 'RoomReveal': { + if (ev.areaId > 0) { + this.knownAreaIds.add(ev.areaId) + } if (this.mapService) { try { this.mapService.onAddRoomDataPacket({ @@ -1271,6 +1334,9 @@ export class OnlineSession { } } + // Synchronize active area and AutomapView from current player position (#549) + this.syncActiveAreaFromPlayerPosition({ emitHudSync: false }) + // Synchronize HudManager via HudModel this.hudModel.syncFromWorld() @@ -1305,6 +1371,8 @@ export class OnlineSession { unit.direction = state.dir64 } } + + this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) } /** @@ -1359,10 +1427,7 @@ export class OnlineSession { const activeLevelView = this.levelView ?? (this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0) - ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [ - this.world.areaId, - ...this.world.getRevealedAreaIds(), - ]) + ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds()) : null) ?? this.mapService?.getActiveLevelView() ?? this.automapView.getLevelView() diff --git a/src/client/ui/minimap.ts b/src/client/ui/minimap.ts index 2d7f3a2..71e38c6 100644 --- a/src/client/ui/minimap.ts +++ b/src/client/ui/minimap.ts @@ -498,6 +498,9 @@ export class Minimap { /** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */ private readonly lastRevealSubPos = new Map() + /** Last drawn level key so manual pan resets when switching levels (#549). */ + private lastLevelKey: string | null = null + /** Diagnostic telemetry from the most recent `draw()` invocation. */ readonly lastRenderStats: AutomapRenderTelemetry = { mode: 'minimap', @@ -869,6 +872,11 @@ export class Minimap { viewport: { readonly width: number; readonly height: number }, options?: MinimapDrawOptions, ): void { + if (this.lastLevelKey !== null && this.lastLevelKey !== level.key) { + this.resetPan() + } + this.lastLevelKey = level.key + const screenW = Math.max(1, Math.trunc(viewport.width)) const screenH = Math.max(1, Math.trunc(viewport.height)) const vp = computeAutomapViewport( diff --git a/tests/client/minimap-level-switch-549.test.ts b/tests/client/minimap-level-switch-549.test.ts new file mode 100644 index 0000000..01bdc0d --- /dev/null +++ b/tests/client/minimap-level-switch-549.test.ts @@ -0,0 +1,244 @@ +/** + * Issue #549 regression: Minimap / Automap must switch synchronously with the main map + * when the player transitions between levels/areas within an Act (outdoor border crossing, + * dungeon warp, town portal, or waypoint) without a new `0x03 LoadAct` packet. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import '../../tools/lib/node-map-loader.ts' +import { AutomapView } from '../../src/client/automap/automap-view.ts' +import { MapService } from '../../src/client/map/map-service.ts' +import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import { createSettingsStore } from '../../src/client/settings/settings-store.ts' +import { Minimap } from '../../src/client/ui/minimap.ts' +import { levelDisplayName } from '../../src/common/world/level-names-zh.ts' + +const FIXTURE_DIR = resolve('tests/fixtures/netproto') +const ROGUE_ENCAMPMENT = 1 +const BLOOD_MOOR = 2 +const DEN_OF_EVIL = 8 + +function createNoopSpriteRenderer() { + return { + begin() {}, + draw() {}, + drawSolidQuad() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + getGl() { + return null + }, + } +} + +function createFakeTileGpuCache() { + return { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return { index: 1, width: 2048, height: 1932 } + }, + } +} + +describe('Issue #549: Minimap / Automap switches synchronously with main map on area transitions', () => { + let mapService: MapService + + beforeAll(async () => { + mapService = await MapService.fromPackedDir(resolve('public/act-packs/tiles')) + }) + + it('AutomapView.syncActiveLevel switches currentAreaId without wiping serverRevealedRooms across the Act', () => { + const automap = new AutomapView({ mapService, initialMode: 'off' }) + const townView = automap.setContext(0x52e4e6ff, 0, ROGUE_ENCAMPMENT) + const townRoom = townView.rooms[0]! + automap.onServerAddRoom(townRoom.tileX, townRoom.tileY, ROGUE_ENCAMPMENT) + expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1) + + const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all') + const bmRoom = bloodMoorView.rooms[0]! + automap.onServerAddRoom(bmRoom.tileX, bmRoom.tileY, BLOOD_MOOR) + + // Switch to Blood Moor via syncActiveLevel: preserves revealed rooms in both Area 1 and Area 2 + automap.syncActiveLevel(BLOOD_MOOR, bloodMoorView) + expect(automap.getCurrentAreaId()).toBe(BLOOD_MOOR) + expect(automap.buildSnapshot().currentAreaId).toBe(BLOOD_MOOR) + expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1) + + const bmMinimapLevel = automap.buildMinimapLevel('ENG') + expect(bmMinimapLevel.levelId).toBe(BLOOD_MOOR) + expect(bmMinimapLevel.levelName).toBe(levelDisplayName(BLOOD_MOOR, 'ENG')) + expect(bmMinimapLevel.cellsX).toBe(bloodMoorView.cellsX) + expect(bmMinimapLevel.cellsY).toBe(bloodMoorView.cellsY) + expect(bmMinimapLevel.markers.some((m) => m.kind === 'exit')).toBe(true) + + // Switch back to Rogue Encampment: Area 1 revealed rooms are still intact + automap.syncActiveLevel(ROGUE_ENCAMPMENT, townView) + expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1) + }) + + it('OnlineSession keeps automapView and world.areaId in lockstep across Area 1 -> Area 2 -> Area 8 -> Area 1 transitions', () => { + const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + settingsStore.set({ automapReveal: 'level' }) + const session = new OnlineSession({ settingsStore, mapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + // Initial state: standing in Rogue Encampment (Area 1), even though Area 2 rooms were pre-revealed by 0x07 + expect(session.world.getRevealedAreaIds()).toContain(BLOOD_MOOR) + expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT) + + // 1. Player walks/moves across border into Blood Moor (Area 2) without a new LoadAct packet + const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all') + const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2 + const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2 + session.handleServerEvent({ + type: 'UnitReassign', + unitType: 'player', + id: session.world.selfId, + at: { x: bmSubX, y: bmSubY }, + snap: true, + }) + + expect(session.sceneSource.getLevelView()?.areaId).toBe(BLOOD_MOOR) + expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(BLOOD_MOOR) + expect(session.world.areaId).toBe(BLOOD_MOOR) + expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(BLOOD_MOOR) + expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe( + levelDisplayName(BLOOD_MOOR, 'ENG'), + ) + + // 2. Player enters Den of Evil (Area 8): server reveals Area 8 room via 0x07 and reassigns player + const denView = mapService.getLevelView(DEN_OF_EVIL, 'all') + const denRoom = denView.rooms[0]! + session.handleServerEvent({ + type: 'RoomReveal', + areaId: DEN_OF_EVIL, + tileX: denRoom.tileX, + tileY: denRoom.tileY, + }) + const denSubX = denView.subOriginX + denView.spawnX * 5 + 2 + const denSubY = denView.subOriginY + denView.spawnY * 5 + 2 + session.handleServerEvent({ + type: 'UnitReassign', + unitType: 'player', + id: session.world.selfId, + at: { x: denSubX, y: denSubY }, + snap: true, + }) + + expect(session.sceneSource.getLevelView()?.areaId).toBe(DEN_OF_EVIL) + expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(DEN_OF_EVIL) + expect(session.world.areaId).toBe(DEN_OF_EVIL) + expect(session.automapView.getCurrentAreaId()).toBe(DEN_OF_EVIL) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(DEN_OF_EVIL) + expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe( + levelDisplayName(DEN_OF_EVIL, 'ENG'), + ) + + // 3. Player returns to Rogue Encampment (Area 1) via Town Portal / walk back + const townView = mapService.getLevelView(ROGUE_ENCAMPMENT, 'all') + const townSubX = townView.subOriginX + townView.spawnX * 5 + 2 + const townSubY = townView.subOriginY + townView.spawnY * 5 + 2 + session.handleServerEvent({ + type: 'UnitReassign', + unitType: 'player', + id: session.world.selfId, + at: { x: townSubX, y: townSubY }, + snap: true, + }) + + expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(ROGUE_ENCAMPMENT) + expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT) + }) + + it('renderSceneSourceFrame + Minimap switches level geometry, HUD title, and resets manual pan when moving across areas', () => { + const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + settingsStore.set({ automapReveal: 'level' }) + const session = new OnlineSession({ settingsStore, mapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + const minimap = new Minimap() + minimap.mode = 'full' + const renderer = createNoopSpriteRenderer() + const tileGpuCache = createFakeTileGpuCache() + + // Frame 1: in Rogue Encampment (Area 1) + renderSceneSourceFrame({ + source: session.sceneSource, + lightingPreset: 'fullbright', + renderer: renderer as never, + tileGpuCache: tileGpuCache as never, + minimap, + automapView: session.automapView, + }) + expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(ROGUE_ENCAMPMENT)) + + // User manually pans the full automap in Area 1 + minimap.pan(32, -24) + expect(minimap.panX).toBe(32) + expect(minimap.panY).toBe(-24) + + // Local predictor moves player into Blood Moor (Area 2) via session.tick() + const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all') + const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2 + const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2 + session.predictor.setPosition(bmSubX, bmSubY) + session.tick(40, 2000) + + // Frame 2: in Blood Moor (Area 2) + const snap2 = renderSceneSourceFrame({ + source: session.sceneSource, + lightingPreset: 'fullbright', + renderer: renderer as never, + tileGpuCache: tileGpuCache as never, + minimap, + automapView: session.automapView, + }) + expect(snap2.levelId).toBe(BLOOD_MOOR) + expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR) + expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(BLOOD_MOOR)) + // Manual pan from Area 1 is automatically reset to (0, 0) upon switching to Area 2 + expect(minimap.panX).toBe(0) + expect(minimap.panY).toBe(0) + expect(minimap.revealedRoomsCount).toBeGreaterThan(0) + }) + + it('CommandMapper resolves local pathfinding against the level containing the player even when 0x07 revealed a neighbouring level', () => { + const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + settingsStore.set({ automapReveal: 'level' }) + const session = new OnlineSession({ settingsStore, mapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + // Precondition: MapService.getActiveLevelView() was switched to Area 2 by 0x07 RoomReveal, + // while player is standing in Area 1 (Rogue Encampment). + expect(mapService.getActiveLevelView()?.areaId).toBe(BLOOD_MOOR) + expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT) + + const playerX = Math.round(session.predictor.getSnapshot().logicalX) + const playerY = Math.round(session.predictor.getSnapshot().logicalY) + const targetScreen = session.camera.subtileToScreen(playerX + 3, playerY) + session.commandMapper.handlePointerDown(targetScreen.x, targetScreen.y, 0, 5000) + session.commandMapper.handlePointerUp(0) + + const predAfterClick = session.predictor.getSnapshot() + expect(predAfterClick.moving).toBe(true) + }) +}) From 71610311fe450fdcc279b1317e0044379d2ccfb4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:15:00 +0000 Subject: [PATCH 2/3] fix(client): isolate per-unit player anim modes (TN/TW/RN/DD) and use 1.13c AnimData.d2 25Hz frame speed (Fixes #544) --- src/client/main.ts | 1 - src/client/motion/interpolate.ts | 2 +- src/client/scene/world-renderer.ts | 167 +++++--- src/client/session/online-session.ts | 110 +++++- src/client/world/client-unit.ts | 2 +- src/client/world/client-world.ts | 95 ++++- src/netproto/d2gs/s2c/movement.ts | 10 +- tests/client/player-anim-modes-544.test.ts | 421 +++++++++++++++++++++ 8 files changed, 738 insertions(+), 70 deletions(-) create mode 100644 tests/client/player-anim-modes-544.test.ts diff --git a/src/client/main.ts b/src/client/main.ts index 49700ac..66238f5 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -958,7 +958,6 @@ async function bootPlayApp(): Promise { automapView: session.automapView, hudManager: session.hudManager, nowMs, - playerMoving: session.predictor.getSnapshot().moving, }) } diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 614d085..3a67336 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -88,7 +88,7 @@ export function resolveRemoteMoveSpeed(options: { const running = options.running ?? (options.moveType !== undefined - ? options.moveType === 0x17 || (options.moveType & 0x02) !== 0 + ? options.moveType === 0x17 || options.moveType === 0x18 || (options.moveType & 0x02) !== 0 : false) if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { return { running, speedSubTilesPerSec: options.speedSubTilesPerSec } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..4051900 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,4 +1,5 @@ import { dir64ToClipDirection } from '../../common/units/direction.ts' +import { PLR_MODES } from '../../common/units/plr-mode.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { npcDisplayName } from '../../common/units/npc-table.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' @@ -175,19 +176,41 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly> = { 520: 'cain5', } -const PLAYER_MODE_TO_CLIP: Readonly> = { - 0: 'dt', - 1: 'nu', - 2: 'wl', - 3: 'rn', - 4: 'gh', - 5: 'tn', - 6: 'tw', - 7: 'a1', - 8: 'a2', - 9: 'bl', - 10: 'sc', - 17: 'dd', +/** + * Secondary `PlrMode.txt` tokens that share HTH DCC sequences in `d2char.mpq` when no dedicated + * HTH DCC file exists for that mode (`KB`/`BL` -> `gh`, `TH`/`KK` -> `a1`, `S1..S4`/`SQ` -> `sc`). + * Primary modes (`dt`, `nu`, `wl`, `rn`, `gh`, `tn`, `tw`, `a1`, `a2`, `sc`, `dd`) never alias. + */ +const PLAYER_HTH_FALLBACK_CLIP: Readonly> = { + bl: 'gh', + th: 'a1', + kk: 'a1', + s1: 'sc', + s2: 'sc', + s3: 'sc', + s4: 'sc', + sq: 'sc', + kb: 'gh', +} + +/** + * Compute 0-based animation frame index from elapsed time `nowMs` and 1.13c 8.8 fixed-point + * animation speed (`speed256`, where `256 = 1.0` frame per 25Hz / 40ms game tick; + * `D2Common.dll` `0x6FDBE510` / `0x6FDBEFF0`). + */ +export function computeAnimDataFrameIndex( + nowMs: number, + speed256: number, + frameCount: number, + loop = true, +): number { + if (frameCount <= 1) return 0 + const safeTicks = Math.max(0, nowMs) / 40 + const rawFrame = Math.floor((safeTicks * speed256) / 256) + if (!loop) { + return Math.min(frameCount - 1, Math.max(0, rawFrame)) + } + return ((rawFrame % frameCount) + frameCount) % frameCount } const MONSTER_MODE_TO_CLIP: Readonly> = { @@ -345,7 +368,6 @@ export interface SceneSourceRenderContext { readonly automapView?: AutomapView | null | undefined readonly hudManager?: HudManager | null | undefined readonly nowMs?: number | undefined - readonly playerMoving?: boolean | undefined readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined } @@ -407,32 +429,42 @@ function drawSceneUnitDrawable( ctx.characterArt ?? null if (charArt !== null) { - const isMoving = - Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 || unit.mode === 6 - const requestedClipKey = - Boolean(ctx.playerMoving) && (unit.mode === 1 || unit.mode === 5) - ? unit.mode === 5 && charArt.clips?.tw - ? 'tw' - : 'wl' - : (PLAYER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) + const modeToken = PLR_MODES[unit.mode]?.token.toLowerCase() + const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken + if (!requestedClipKey) { + reportMissingArt( + 'player-mode', + `${unit.token}:${unit.mode}`, + `Invalid player mode=${unit.mode} for token=${unit.token}`, + ) + return + } + const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey] const clip = charArt.clips?.[requestedClipKey] ?? - (isMoving ? charArt.clips?.wl : charArt.clips?.nu) - const dirCount = Math.max(1, clip?.directions ?? charArt.directions) + (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) + if (!clip || !(clip.speed > 0)) { + reportMissingArt( + 'player-clip', + `${unit.token}:${requestedClipKey}`, + `Missing player clip or AnimData.d2 speed for token=${unit.token} clip=${requestedClipKey} mode=${unit.mode}`, + ) + return + } + const dirCount = Math.max(1, clip.directions || charArt.directions) const direction = dir64ToClipDirection(unit.direction, dirCount) - const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand - const groupIndex = baseGroup + direction - const group = - charArt.groups[groupIndex] ?? - charArt.groups[charArt.stand + direction] ?? - charArt.groups[0] + const groupIndex = clip.group + direction + const group = charArt.groups[groupIndex] const frameCount = Math.max(1, group?.length ?? 1) + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + clip.loop !== false const frameIndex = - unit.isCorpse || requestedClipKey === 'dd' + unit.isCorpse && requestedClipKey !== 'dt' ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 65) % frameCount - : Math.floor(nowMs / 140) % frameCount + : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -485,19 +517,29 @@ function drawSceneUnitDrawable( art.clips?.[requestedClipKey] ?? (isMoving ? art.clips?.wl : undefined) ?? art.clips?.nu - const dirCount = Math.max(1, clip?.directions ?? art.directions) + if (!clip || !(clip.speed > 0)) { + reportMissingArt( + 'monster-clip', + `${unit.classId}:${requestedClipKey}`, + `Missing monster clip or AnimData.d2 speed for classId=${unit.classId} token=${unit.token} clip=${requestedClipKey}`, + ) + return + } + const dirCount = Math.max(1, clip.directions || art.directions) const direction = dir64ToClipDirection(unit.direction, dirCount) - const baseGroup = - clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset + const baseGroup = clip.group const groupIndex = baseGroup + direction const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0] const frameCount = Math.max(1, group?.length ?? 1) + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + clip.loop !== false const frameIndex = - unit.isCorpse || requestedClipKey === 'dd' + unit.isCorpse && requestedClipKey !== 'dt' ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 75) % frameCount - : Math.floor(nowMs / 130) % frameCount + : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -682,9 +724,16 @@ function drawSceneUnitDrawable( if (!shouldLoop) { frameIndex = frameCount - 1 } else { - const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 128 - const frameDurationMs = speed > 0 ? Math.max(40, Math.round((256 * 40) / speed)) : 100 - frameIndex = Math.abs(Math.floor(nowMs / frameDurationMs)) % frameCount + const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 + if (!(speed > 0)) { + reportMissingArt( + 'object-clip', + `${unit.classId}:${clipKey}`, + `Missing frameDelta/speed for object classId=${unit.classId} mode=${modeIdx}`, + ) + return + } + frameIndex = computeAnimDataFrameIndex(nowMs, speed, frameCount, true) } } const frame = group?.[frameIndex] ?? group?.[0] @@ -738,11 +787,22 @@ function drawSceneMissileDrawable( ctx.missileArtMap?.get(missile.missileKey) ?? ctx.missileArtMap?.get(missile.celFile.toLowerCase()) if (art !== undefined) { + const animSpeed = art.meta.animSpeed + if (!(animSpeed > 0)) { + reportMissingArt( + 'missile-speed', + missile.missileKey || missile.celFile || String(missile.missileClassId), + `Missing animSpeed for missile key=${missile.missileKey}`, + ) + return + } const dirCount = Math.max(1, art.meta.directions) const dir = ((missile.direction % dirCount) + dirCount) % dirCount const group = art.frames[dir] ?? art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (missile.frame + Math.floor(nowMs / 50)) % frameCount + const frameIdx = + (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) % + frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -777,9 +837,20 @@ function drawSceneOverlayDrawable( ctx.overlayArtMap?.get(overlay.token) ?? ctx.overlayArtMap?.get(overlay.token.toLowerCase()) if (art !== undefined) { + const animRate = art.meta.animRate + if (!(animRate > 0)) { + reportMissingArt( + 'overlay-speed', + overlay.token || String(overlay.overlayId), + `Missing animRate for overlay token=${overlay.token}`, + ) + return + } const group = art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (overlay.frame + Math.floor(nowMs / 60)) % frameCount + const frameIdx = + (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) % + frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -826,7 +897,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.flush() } if (ctx.hudManager) { - ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot @@ -1011,7 +1082,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram } if (ctx.hudManager) { - ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..d229d08 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -937,6 +937,32 @@ export class OnlineSession { return levelView === null ? null : isTownLevel(levelView.areaId) } + /** + * Resolve whether `(subX, subY)` is in a town level (`DUNGEON_IsRoomInTown` equivalent): + * prefers the exact `LevelView` containing `(subX, subY)` when `mapService` is loaded, + * and falls back to the active `world.areaId` / `levelView.areaId`. + */ + private isSubTileInTown(subX: number, subY: number): boolean { + if (this.mapService && this.world.areaId > 0 && (subX !== 0 || subY !== 0)) { + const levelView = this.mapService.findLevelViewAtWorldSubTile( + Math.round(subX), + Math.round(subY), + [this.world.areaId, ...this.world.getRevealedAreaIds()], + ) + if (levelView !== null) { + return isTownLevel(levelView.areaId) + } + } + const activeAreaId = + this.world.areaId > 0 + ? this.world.areaId + : (this.levelView?.areaId ?? + this.mapService?.getActiveLevelView()?.areaId ?? + this.automapView.getLevelView()?.areaId ?? + 0) + return isTownLevel(activeAreaId) + } + // ─── Outbound ClientCommand Dispatch ───────────────────────── /** @@ -1186,12 +1212,29 @@ export class OnlineSession { const evAny = ev as any const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType) const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1) - const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) - const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) + const unit = this.world.getUnit(evTypeId as UnitType, evUnitId) + const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) + const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) + const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX + const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y const isLocalPlayer = evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 + if (unit && unit.unitType === 'player') { + const posForTownX = ev.mode === 'stop' ? toX : Number(fromX ?? unit.x ?? toX) + const posForTownY = ev.mode === 'stop' ? toY : Number(fromY ?? unit.y ?? toY) + const inTown = this.isSubTileInTown(posForTownX, posForTownY) + if (ev.mode === 'stop') { + unit.mode = inTown ? 5 : 1 + } else if (ev.mode === 'run') { + unit.mode = 3 + } else if (ev.mode === 'knockback') { + unit.mode = 19 + } else { + unit.mode = inTown ? 6 : 2 + } + } if (isLocalPlayer) { if (ev.mode === 'stop') { this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) @@ -1303,6 +1346,24 @@ export class OnlineSession { unit.x = state.x unit.y = state.y unit.direction = state.dir64 + if (unit.unitType === 'player' && unit.id !== this.world.selfId) { + const unitInTown = this.isSubTileInTown(state.x, state.y) + if (state.moving && unit.moveMode !== 'stop') { + if (state.running || unit.mode === 3) { + unit.mode = 3 + } else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + unit.mode = unitInTown ? 6 : 2 + } + } else if (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + unit.mode = unitInTown ? 5 : 1 + unit.moveMode = 'stop' + } + } else if (unit.unitType === 'monster' && !state.moving) { + if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { + unit.mode = 1 + unit.moveMode = 'stop' + } + } } } } @@ -1322,17 +1383,31 @@ export class OnlineSession { const selfUnit = this.world.getUnit('player', this.world.selfId) const isSelfDead = this.world.isSelfDead || this.world.isDead const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined + const selfInTown = this.isSubTileInTown(selfX, selfY) + const neutralMode = selfInTown ? 5 : 1 + const walkMode = selfInTown ? 6 : 2 + const unitExplicitMode = + selfUnit?.mode !== undefined && + selfUnit.mode !== 0 && + selfUnit.mode !== 1 && + selfUnit.mode !== 2 && + selfUnit.mode !== 3 && + selfUnit.mode !== 5 && + selfUnit.mode !== 6 + ? selfUnit.mode + : undefined const selfMode = isSelfDead - ? 12 + ? (selfUnit?.mode === 0 ? 0 : 17) : selfCastState ? selfCastState.mode || 10 : predSnap.moving ? predSnap.running ? 3 - : 2 - : selfUnit?.mode && selfUnit.mode > 1 - ? selfUnit.mode - : 1 + : walkMode + : (unitExplicitMode ?? neutralMode) + if (selfUnit !== undefined && !selfCastState) { + selfUnit.mode = selfMode + } const selfDirection = selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64 out.push({ @@ -1435,9 +1510,26 @@ export class OnlineSession { continue } - const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0) + const isCorpse = + (u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) || + (u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0)) const castState = !isCorpse ? u.castState : undefined - const mode = castState ? castState.mode || u.mode : u.mode + let mode = castState ? castState.mode || u.mode : u.mode + if (u.unitType === 'player' && !isCorpse && !castState) { + const unitInTown = this.isSubTileInTown(ux, uy) + if (interp?.moving && u.moveMode !== 'stop') { + mode = interp.running || u.mode === 3 ? 3 : unitInTown ? 6 : 2 + } else if ( + u.mode === 1 || + u.mode === 5 || + (interp !== null && interp !== undefined && !interp.moving && (u.mode === 2 || u.mode === 3 || u.mode === 6)) + ) { + mode = unitInTown ? 5 : 1 + } else if (u.mode === 2 || u.mode === 6) { + mode = unitInTown ? 6 : 2 + } + u.mode = mode + } const targetX = castState?.targetX ?? u.targetX const targetY = castState?.targetY ?? u.targetY diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..7ea2a28 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -73,7 +73,7 @@ export class ClientUnit { this.name = snapshot.name this.x = snapshot.x this.y = snapshot.y - this.mode = snapshot.mode ?? 0 + this.mode = snapshot.mode ?? (this.unitType === 'player' || this.unitType === 'monster' ? 1 : 0) this.direction = snapshot.direction ?? 0 this.lifePct = snapshot.lifePct ?? 128 this.flags = snapshot.flags ?? 0 diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..1c70e00 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -19,6 +19,7 @@ import { } from '../../netproto/index.ts' import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts' import { dir64FromPoints } from '../../common/units/direction.ts' +import { isTownLevel } from '../../common/world/scene-population.ts' import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts' import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts' import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts' @@ -430,9 +431,22 @@ export class ClientWorld { return this._soundQueue.splice(0, this._soundQueue.length) } + private syncPlayerTownModes(): void { + const inTown = isTownLevel(this.areaId) + for (const unit of this._units.values()) { + if (unit.unitType !== 'player') continue + if (unit.mode === 1 || unit.mode === 5) { + unit.mode = inTown ? 5 : 1 + } else if (unit.mode === 2 || unit.mode === 6) { + unit.mode = inTown ? 6 : 2 + } + } + } + tick(dtMs = 40): void { this.frameCount++ const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10) + const inTown = isTownLevel(this.areaId) for (const unit of this._units.values()) { if (unit.dead) continue if (unit.targetX === undefined || unit.targetY === undefined) continue @@ -443,6 +457,13 @@ export class ClientWorld { if (dist <= step) { unit.x = unit.targetX unit.y = unit.targetY + if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6)) { + unit.mode = inTown ? 5 : 1 + unit.moveMode = 'stop' + } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)) { + unit.mode = 1 + unit.moveMode = 'stop' + } } else { unit.x += (dx / dist) * step unit.y += (dy / dist) * step @@ -469,12 +490,14 @@ export class ClientWorld { const key = makeUnitKey(unitType, id) let unit = this._units.get(key) if (!unit) { + const typeName = unitTypeFromId(unitType) unit = new ClientUnit({ - unitType: unitTypeFromId(unitType), + unitType: typeName, id, classId: 0, x: 0, y: 0, + ...(typeName === 'player' ? { mode: isTownLevel(this.areaId) ? 5 : 1 } : {}), }) this._units.set(key, unit) } @@ -527,6 +550,7 @@ export class ClientWorld { type: 'UnitMove', unitType: unitTypeFromId(ev.unitType ?? 0), id: Number(ev.unitId ?? ev.id ?? 1), + mode: ev.mode ?? 'walk', from: { x: Number(ev.currentX ?? ev.x ?? 0), y: Number(ev.currentY ?? ev.y ?? 0), @@ -567,11 +591,12 @@ export class ClientWorld { value: Number(ev.value ?? 0), } } else if (ev.type === 'Damage') { + const isPlayerTarget = unitTypeFromId(ev.unitType ?? 1) === 'player' ev = { type: 'UnitMode', unitType: ev.unitType ?? 1, id: Number(ev.unitId ?? ev.id ?? 0), - mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)), + mode: Number(ev.mode ?? (ev.lifePct === 0 ? (isPlayerTarget ? 17 : 12) : (isPlayerTarget ? 4 : 3))), lifePct: Number(ev.lifePct ?? 0), } } @@ -595,6 +620,7 @@ export class ClientWorld { if (ev.objectSeed !== undefined) { this.objectSeed = ev.objectSeed >>> 0 } + this.syncPlayerTownModes() break case 'LoadComplete': @@ -613,6 +639,7 @@ export class ClientWorld { this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`) if (this.areaId === 0 && ev.areaId > 0) { this.areaId = ev.areaId + this.syncPlayerTownModes() } break @@ -629,6 +656,13 @@ export class ClientWorld { } else { unit.updateFromSnapshot(ev.unit) } + if (ev.unit.mode === undefined) { + if (unit.unitType === 'player') { + unit.mode = isTownLevel(this.areaId) ? 5 : 1 + } else if (unit.unitType === 'monster') { + unit.mode = unit.lifePct <= 0 ? 12 : 1 + } + } if (unit.unitType === 'monster') { this._monsters.set(unit.id, unit) if (this._merc && unit.id === this._merc.mercId) { @@ -691,9 +725,56 @@ export class ClientWorld { unit.x = ev.to.x unit.y = ev.to.y } - unit.targetX = ev.to.x - unit.targetY = ev.to.y + let targetX = ev.to.x + let targetY = ev.to.y + if ( + ev.target && + ((targetX === 0 && targetY === 0) || + (ev.from !== undefined && targetX === ev.from.x && targetY === ev.from.y)) + ) { + const targetUnit = this.getUnit(ev.target.unitType, ev.target.id) + if (targetUnit && (targetUnit.x !== 0 || targetUnit.y !== 0)) { + targetX = targetUnit.x + targetY = targetUnit.y + } else if ( + unitTypeFromId(ev.target.unitType) === 'player' && + (ev.target.id === this.selfId || this.selfId === 0) && + (this._self.x !== 0 || this._self.y !== 0) + ) { + targetX = this._self.x + targetY = this._self.y + } + } + unit.targetX = targetX + unit.targetY = targetY unit.targetUnit = ev.target + const moveMode = ev.mode ?? 'walk' + unit.moveMode = moveMode + if (targetX !== unit.x || targetY !== unit.y) { + unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) + } + if (unit.unitType === 'player') { + const inTown = isTownLevel(this.areaId) + if (moveMode === 'stop') { + unit.mode = inTown ? 5 : 1 + } else if (moveMode === 'run') { + unit.mode = 3 + } else if (moveMode === 'knockback') { + unit.mode = 19 + } else { + unit.mode = inTown ? 6 : 2 + } + } else if (unit.unitType === 'monster') { + if (moveMode === 'stop') { + if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { + unit.mode = 1 + } + } else if (moveMode === 'run') { + unit.mode = 15 + } else { + unit.mode = 2 + } + } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { this._self.x = ev.from.x @@ -702,9 +783,9 @@ export class ClientWorld { this._self.x = ev.to.x this._self.y = ev.to.y } - if (ev.to.x !== 0 || ev.to.y !== 0) { - this._self.targetX = ev.to.x - this._self.targetY = ev.to.y + if (targetX !== 0 || targetY !== 0) { + this._self.targetX = targetX + this._self.targetY = targetY } } break diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index cdc03e0..dbc74dc 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -14,9 +14,13 @@ function clampUnitType(raw: number): UnitType { } function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' { - // In D2Client.dll: 0x17 / 0x02 / 0x04 / bit 0x10 = run, 0x20 = knockback - if (rawMoveType === 0x20 || rawMoveType === 0x09) return 'knockback' - if (rawMoveType === 0x17 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) return 'run' + // In D2Client.dll 0x6fac9830: 0x17 (0x0F run) / 0x18 (0x10 run) / 0x02 = run, 0x13/0x14/0x20/0x09 = knockback + if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x13 || rawMoveType === 0x14) { + return 'knockback' + } + if (rawMoveType === 0x17 || rawMoveType === 0x18 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) { + return 'run' + } return 'walk' } diff --git a/tests/client/player-anim-modes-544.test.ts b/tests/client/player-anim-modes-544.test.ts new file mode 100644 index 0000000..1f215e7 --- /dev/null +++ b/tests/client/player-anim-modes-544.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from 'vitest' +import { AutomapView } from '../../src/client/automap/automap-view.ts' +import { + clearMissingArtEntries, + computeAnimDataFrameIndex, + getMissingArtEntries, + renderSceneSourceFrame, +} from '../../src/client/scene/world-renderer.ts' +import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/client/view/scene-source.ts' +import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts' +import { PLR_MODES } from '../../src/common/units/plr-mode.ts' + +function makeTaggedCharArt(): LoadedCharacter { + const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [ + { key: 'dt', group: 0, frames: 4, speed: 256, loop: false }, + { key: 'nu', group: 1, frames: 8, speed: 128, loop: true }, + { key: 'wl', group: 2, frames: 8, speed: 256, loop: true }, + { key: 'rn', group: 3, frames: 8, speed: 256, loop: true }, + { key: 'gh', group: 4, frames: 4, speed: 256, loop: false }, + { key: 'tn', group: 5, frames: 16, speed: 80, loop: true }, + { key: 'tw', group: 6, frames: 8, speed: 256, loop: true }, + { key: 'a1', group: 7, frames: 8, speed: 256, loop: false }, + { key: 'a2', group: 8, frames: 8, speed: 256, loop: false }, + { key: 'sc', group: 9, frames: 8, speed: 256, loop: false }, + { key: 'dd', group: 10, frames: 1, speed: 256, loop: false }, + ] + const groups = modes.map((m) => + Array.from({ length: m.frames }, (_, fIdx) => ({ + x: m.group * 100 + fIdx, + y: 0, + width: 32, + height: 64, + anchorX: -16, + anchorY: -56, + tag: `${m.key}_${fIdx}`, + })), + ) + const clips = Object.fromEntries( + modes.map((m) => [ + m.key, + { + group: m.group, + directions: 1, + frames: m.frames, + speed: m.speed, + loop: m.loop, + events: [], + }, + ]), + ) + return { + handle: { index: 1, width: 512, height: 512 } as never, + groups: groups as never, + directions: 1, + walk: 2, + stand: 1, + members: 1, + clips, + } +} + +describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isolation & AnimData.d2 Speed', () => { + it('1. isolates local player movement from remote standing players in the same scene', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Rogue Encampment (Town) + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 0, + id: 1, + classId: 1, // Sorceress ('so') + name: 'LocalSorc', + x: 5100, + y: 5050, + }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { + unitType: 0, + id: 2, + classId: 1, // Remote Sorceress ('so') + name: 'RemoteSorc', + x: 5104, + y: 5050, + }, + }) + + // Start local player walking in town (mode = 6 TW) + session.predictor.setPath([{ x: 5120, y: 5050 }], false) + session.tick(40, 100) + + const renderables = session.buildRenderableUnits() + const localPlayer = renderables.find((u) => u.id === 1)! + const remotePlayer = renderables.find((u) => u.id === 2)! + expect(localPlayer.mode).toBe(6) // PLRMODE_TOWNWALK (TW) + expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN) + + // Render scene frame and inspect drawn sprite tags for both players + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const baseX = levelView.subOriginX + 50 + const baseY = levelView.subOriginY + 50 + + const drawnTags: string[] = [] + const fakeRenderer = { + begin() {}, + draw(frame: { tag?: string }) { + if (frame.tag) drawnTags.push(frame.tag) + }, + setLighting() {}, + setPalette() {}, + flush() {}, + } + const fakeTileGpuCache = { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return undefined + }, + } + + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 1, + actId: 1, + levelId: 1, + playerSubX: baseX, + playerSubY: baseY, + units: [ + { + kind: 'player', + id: 1, + classId: 1, + x: baseX, + y: baseY, + mode: localPlayer.mode, + direction: 0, + isLocalPlayer: true, + }, + { + kind: 'player', + id: 2, + classId: 1, + x: baseX + 4, + y: baseY, + mode: remotePlayer.mode, + direction: 0, + isLocalPlayer: false, + }, + ], + levelView, + }), + '800x600', + ) + + const charArt = makeTaggedCharArt() + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + tileGpuCache: fakeTileGpuCache as never, + characterArtByToken: new Map([['so', charArt]]), + nowMs: 80, + }) + + // Local player drew TW frame (`tw_2`), while remote standing player drew TN frame (`tn_0`) + expect(drawnTags).toContain('tw_2') + expect(drawnTags).toContain('tn_0') + expect(drawnTags.some((t) => t.startsWith('wl_'))).toBe(false) + }) + + it('2. transitions remote player mode on 0x0F PlayerMove, 0x10 PlayerMoveToTarget, 0x0D PlayerStop, and segment completion', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Town + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { unitType: 0, id: 2, classId: 3, name: 'RemotePal', x: 5105, y: 5050 }, + }) + + // Initially standing in town -> mode 5 (TN) + expect(session.world.getUnit('player', 2)?.mode).toBe(5) + + // 0x0F PlayerMove (walk: moveType = 0x01) from (5105, 5050) to (5115, 5050) + const pkt0x0FWalk = new Uint8Array(16) + const dvWalk = new DataView(pkt0x0FWalk.buffer) + dvWalk.setUint8(0, 0x0f) + dvWalk.setUint8(1, 0) // unitType = player + dvWalk.setUint32(2, 2, true) // unitId = 2 + dvWalk.setUint8(6, 0x01) // moveType = walk + dvWalk.setUint16(7, 5115, true) // targetX + dvWalk.setUint16(9, 5050, true) // targetY + dvWalk.setUint8(11, 0) + dvWalk.setUint16(12, 5105, true) // currentX + dvWalk.setUint16(14, 5050, true) // currentY + session.handleServerEvent(decode0x0FPlayerMove(pkt0x0FWalk)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(6) // TW in town + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(6) + + // 0x10 PlayerMoveToTarget (run: moveType = 0x18) targeting local player id = 1 at (5100, 5050) + const pkt0x10Run = new Uint8Array(16) + const dvRun = new DataView(pkt0x10Run.buffer) + dvRun.setUint8(0, 0x10) + dvRun.setUint8(1, 0) // unitType = player + dvRun.setUint32(2, 2, true) // unitId = 2 + dvRun.setUint8(6, 0x18) // moveType = 0x18 (run to target in 1.13c D2Client 0x6fac9830) + dvRun.setUint8(7, 0) // targetType = player + dvRun.setUint32(8, 1, true) // targetId = 1 + dvRun.setUint16(12, 5110, true) // currentX + dvRun.setUint16(14, 5050, true) // currentY + session.handleServerEvent(decode0x10PlayerMoveToTarget(pkt0x10Run)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(3) // RN (even in town) + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(3) + + // Advance interpolation until remote player reaches target (5100, 5050) -> returns to TN (5) + for (let i = 0; i < 30; i++) { + session.tick(100, i * 100) + } + expect(session.world.getUnit('player', 2)?.mode).toBe(5) + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5) + + // Start another run, then stop immediately via 0x0D PlayerStop + session.handleServerEvent({ + type: 'UnitMove', + unitType: 0, + id: 2, + mode: 'run', + from: { x: 5100, y: 5050 }, + to: { x: 5120, y: 5050 }, + }) + expect(session.world.getUnit('player', 2)?.mode).toBe(3) + + const pkt0x0DStop = new Uint8Array(13) + const dvStop = new DataView(pkt0x0DStop.buffer) + dvStop.setUint8(0, 0x0d) + dvStop.setUint8(1, 0) + dvStop.setUint32(2, 2, true) + dvStop.setUint8(6, 0x07) // actionCode = neutral + dvStop.setUint16(7, 5102, true) + dvStop.setUint16(9, 5050, true) + dvStop.setUint8(11, 0) + dvStop.setUint8(12, 128) + session.handleServerEvent(decode0x0DPlayerStop(pkt0x0DStop)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(5) // Back to TN in town + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5) + }) + + it('3. switches local player between town (TN=5, TW=6, RN=3) and wilderness (NU=1, WL=2, RN=3) and uses DD=17 (never KK=12) on death', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Rogue Encampment (Town) + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 }, + }) + + // Town Idle -> TN (5) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(5) + expect(PLR_MODES[5]?.token).toBe('TN') + + // Town Walk -> TW (6) + session.predictor.setPath([{ x: 5120, y: 5050 }], false) + session.tick(40, 40) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(6) + expect(PLR_MODES[6]?.token).toBe('TW') + + // Town Run -> RN (3) (1.13c D2Game sub_6FC7F600: running in town still uses PLRMODE_RUN=3) + session.predictor.setPath([{ x: 5140, y: 5050 }], true) + session.tick(40, 80) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3) + expect(PLR_MODES[3]?.token).toBe('RN') + + // Enter Blood Moor (areaId = 2, non-town) while running -> stays RN (3) + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 2, + }) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3) + + // Switch to walking in Blood Moor -> WL (2) + session.predictor.setPath([{ x: 5160, y: 5050 }], false) + session.tick(40, 120) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(2) + expect(PLR_MODES[2]?.token).toBe('WL') + + // Stop in Blood Moor -> NU (1) + session.predictor.setPosition(5160, 5050) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(1) + expect(PLR_MODES[1]?.token).toBe('NU') + + // Local player dies -> mode must be 17 (DD = Dead/Corpse), never 12 (KK = Kick) + expect(PLR_MODES[12]?.token).toBe('KK') + expect(PLR_MODES[17]?.token).toBe('DD') + session.handleServerEvent({ + type: 'LifeMana', + life: 0, + mana: 0, + stamina: 0, + at: { x: 5160, y: 5050 }, + vx: 0, + vy: 0, + }) + const deadSelf = session.buildRenderableUnits().find((u) => u.id === 1)! + expect(deadSelf.isCorpse).toBe(true) + expect(deadSelf.mode).toBe(17) + expect(deadSelf.mode).not.toBe(12) + }) + + it('4. advances frames at 1.13c 25Hz (40ms tick) 8.8 fixed-point AnimData.d2 speed and reports missing clip speed', () => { + // speed = 256 -> 1 frame per 40ms tick (25 fps) + expect(computeAnimDataFrameIndex(0, 256, 8)).toBe(0) + expect(computeAnimDataFrameIndex(39, 256, 8)).toBe(0) + expect(computeAnimDataFrameIndex(40, 256, 8)).toBe(1) + expect(computeAnimDataFrameIndex(80, 256, 8)).toBe(2) + expect(computeAnimDataFrameIndex(320, 256, 8)).toBe(0) // loops at 8 frames + + // speed = 128 (SONUHTH) -> 1 frame per 80ms (12.5 fps) + expect(computeAnimDataFrameIndex(0, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(40, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(79, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(80, 128, 8)).toBe(1) + expect(computeAnimDataFrameIndex(160, 128, 8)).toBe(2) + + // speed = 80 (SOTNHTH) -> 1 frame per 128ms (7.8125 fps) + expect(computeAnimDataFrameIndex(0, 80, 16)).toBe(0) + expect(computeAnimDataFrameIndex(127, 80, 16)).toBe(0) + expect(computeAnimDataFrameIndex(128, 80, 16)).toBe(1) + expect(computeAnimDataFrameIndex(256, 80, 16)).toBe(2) + + // Non-looping death animation (DT, loop = false) clamps to frameCount - 1 + expect(computeAnimDataFrameIndex(10000, 256, 4, false)).toBe(3) + + // Fail-fast diagnostic when clip speed metadata is missing + clearMissingArtEntries() + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const baseX = levelView.subOriginX + 50 + const baseY = levelView.subOriginY + 50 + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 1, + actId: 1, + levelId: 1, + playerSubX: baseX, + playerSubY: baseY, + units: [ + { + kind: 'player', + id: 1, + classId: 1, + x: baseX, + y: baseY, + mode: 5, // TN + direction: 0, + isLocalPlayer: true, + }, + ], + levelView, + }), + '800x600', + ) + const brokenCharArt: LoadedCharacter = { + handle: { index: 1, width: 64, height: 64 } as never, + groups: [[{ x: 0, y: 0, width: 32, height: 64 }]], + directions: 1, + walk: 0, + stand: 0, + members: 1, + // Missing 'tn' clip! + clips: {}, + } + const fakeRenderer = { + begin() {}, + draw() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + } + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + characterArtByToken: new Map([['so', brokenCharArt]]), + nowMs: 0, + }) + expect( + getMissingArtEntries().some((e) => e.category === 'player-clip' && e.id.toLowerCase() === 'so:tn'), + ).toBe(true) + clearMissingArtEntries() + }) +}) From 45aa95ce2db9cebb37ae59fc827626b1714c1899 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:19:02 +0000 Subject: [PATCH 3/3] fix(client/netproto): align 1.13c monster/NPC mode decoding and prevent initial death state (#551) - Decode the initial 4-bit bitstream in 0xAC AssignNPC as monster mode nMode (D2Game.dll 0x6fcacac9, D2Client.dll 0x6fb5e462) and seed initial 1.13c facing via 0x6ac690c5 (D2Client.dll 0x6fafd512) - Map 0x69/0x6A/0x6B state command byte pkt[5] via 1.13c D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE, inverse of D2Game.dll 0x6fd1a718) and only apply pkt[10]/pkt[11] as lifePct on 0x06 (GH) or 0x08/0x09 (DT/DD) so non-death commands (e.g. Charsi 0x6B anvil sequence 0x0C) never zero out lifePct - Default alive monsters and players to mode=1 (NU) instead of mode=0 (DT) across ClientUnit, ClientWorld, OnlineSession, buildSceneDrawables, and drawUnitSprite - Transition monster/player modes across UnitMove and OnlineSession interpolator ticks (walk=2, run=15/3, knockback=9/19, stop=1) - Add 1.13c unit and live .d2cap regression tests Closes #551 --- src/client/motion/interpolate.ts | 4 +- src/client/scene/world-renderer.ts | 14 ++- src/client/session/online-session.ts | 50 +++++++-- src/client/view/unit-drawables.ts | 2 +- src/client/world/client-unit.ts | 30 ++++- src/client/world/client-world.ts | 44 ++++++-- src/netproto/d2gs/s2c/movement.ts | 20 +++- src/netproto/d2gs/s2c/units.ts | 158 +++++++++++++++++++++------ tests/client/client-world.test.ts | 94 ++++++++++++++++ tests/netproto/d2gs-s2c.test.ts | 112 +++++++++++++++++++ 10 files changed, 466 insertions(+), 62 deletions(-) diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 3a67336..ed187bf 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -88,7 +88,9 @@ export function resolveRemoteMoveSpeed(options: { const running = options.running ?? (options.moveType !== undefined - ? options.moveType === 0x17 || options.moveType === 0x18 || (options.moveType & 0x02) !== 0 + ? options.moveType === 0x17 || + options.moveType === 0x18 || + (options.moveType & 0x02) !== 0 : false) if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { return { running, speedSubTilesPerSec: options.speedSubTilesPerSec } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index b7f51be..f441603 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -505,13 +505,15 @@ function drawSceneUnitDrawable( art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap) } if (art !== undefined) { - const isMoving = unit.mode === 2 || unit.mode === 13 || unit.mode === 15 + const isMoving = unit.mode === 2 || unit.mode === 15 const requestedClipKey = unit.isCorpse - ? art.clips?.dd - ? 'dd' - : art.clips?.dt - ? 'dt' - : 'nu' + ? unit.mode === 0 && art.clips?.dt + ? 'dt' + : art.clips?.dd + ? 'dd' + : art.clips?.dt + ? 'dt' + : 'nu' : (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) const clip = art.clips?.[requestedClipKey] ?? diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 654bfe6..46e54a5 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1415,20 +1415,32 @@ export class OnlineSession { if (unit.unitType === 'player' && unit.id !== this.world.selfId) { const unitInTown = this.isSubTileInTown(state.x, state.y) if (state.moving && unit.moveMode !== 'stop') { - if (state.running || unit.mode === 3) { + if (unit.moveMode === 'knockback') { + unit.mode = 19 + } else if (state.running || unit.mode === 3) { unit.mode = 3 } else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { unit.mode = unitInTown ? 6 : 2 } - } else if (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + } else if ( + unit.mode === 2 || + unit.mode === 3 || + unit.mode === 6 || + unit.mode === 19 || + unit.mode === 1 || + unit.mode === 5 + ) { unit.mode = unitInTown ? 5 : 1 unit.moveMode = 'stop' } - } else if (unit.unitType === 'monster' && !state.moving) { - if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { - unit.mode = 1 - unit.moveMode = 'stop' - } + } else if ( + unit.unitType === 'monster' && + !state.moving && + unit.lifePct > 0 && + (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) + ) { + unit.mode = 1 + unit.moveMode = 'stop' } } } @@ -1583,17 +1595,37 @@ export class OnlineSession { if (u.unitType === 'player' && !isCorpse && !castState) { const unitInTown = this.isSubTileInTown(ux, uy) if (interp?.moving && u.moveMode !== 'stop') { - mode = interp.running || u.mode === 3 ? 3 : unitInTown ? 6 : 2 + mode = + u.moveMode === 'knockback' + ? 19 + : interp.running || u.mode === 3 + ? 3 + : unitInTown + ? 6 + : 2 } else if ( u.mode === 1 || u.mode === 5 || - (interp !== null && interp !== undefined && !interp.moving && (u.mode === 2 || u.mode === 3 || u.mode === 6)) + (interp !== null && + interp !== undefined && + !interp.moving && + (u.mode === 2 || u.mode === 3 || u.mode === 6 || u.mode === 19)) ) { mode = unitInTown ? 5 : 1 } else if (u.mode === 2 || u.mode === 6) { mode = unitInTown ? 6 : 2 } u.mode = mode + } else if (u.unitType === 'monster') { + if (isCorpse) { + mode = u.mode === 0 ? 0 : 12 + } else if (castState) { + mode = castState.mode || u.mode || 1 + } else if (interp?.moving && u.moveMode !== 'stop') { + mode = u.moveMode === 'knockback' ? 9 : interp.running ? 15 : 2 + } else { + mode = u.mode || 1 + } } const targetX = castState?.targetX ?? u.targetX const targetY = castState?.targetY ?? u.targetY diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0650ee8..2920138 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -487,7 +487,7 @@ export function buildSceneDrawables( const effectiveTargetY = unit.targetY ?? activeCastState?.targetY const effectiveMode = activeCastState ? activeCastState.mode || unit.mode || 10 - : (unit.mode ?? 0) + : (unit.mode ?? (unit.kind === 'player' || unit.kind === 'monster' ? (isCorpse ? 12 : 1) : 0)) const animMode = unit.animMode ?? (!isCorpse && (effectiveMode === 7 || effectiveMode === 4) diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 7ea2a28..4ace995 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -73,9 +73,17 @@ export class ClientUnit { this.name = snapshot.name this.x = snapshot.x this.y = snapshot.y - this.mode = snapshot.mode ?? (this.unitType === 'player' || this.unitType === 'monster' ? 1 : 0) - this.direction = snapshot.direction ?? 0 this.lifePct = snapshot.lifePct ?? 128 + const defaultMode = + this.unitType === 'monster' + ? this.lifePct <= 0 + ? 12 + : 1 + : this.unitType === 'player' + ? 1 + : 0 + this.mode = snapshot.mode ?? defaultMode + this.direction = snapshot.direction ?? 0 this.flags = snapshot.flags ?? 0 this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined) this.objectState = snapshot.objectState @@ -131,6 +139,13 @@ export class ClientUnit { } if (snapshot.lifePct !== undefined) { this.lifePct = snapshot.lifePct + if (snapshot.mode === undefined && this.unitType === 'monster') { + if (this.lifePct <= 0) { + this.mode = 12 + } else if (this.mode === 0 || this.mode === 12) { + this.mode = 1 + } + } } if (snapshot.flags !== undefined) { this.flags = snapshot.flags @@ -183,6 +198,17 @@ export class ClientUnit { this.y = from.y } this.moveMode = mode + if (!this.dead && (this.unitType === 'monster' || this.unitType === 'player')) { + if (mode === 'stop') { + this.mode = 1 + } else if (mode === 'run') { + this.mode = this.unitType === 'monster' ? 15 : 3 + } else if (mode === 'knockback') { + this.mode = this.unitType === 'monster' ? 9 : 19 + } else { + this.mode = 2 + } + } this.targetX = to.x this.targetY = to.y this.targetUnit = target diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 1c70e00..534ca6d 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -457,10 +457,10 @@ export class ClientWorld { if (dist <= step) { unit.x = unit.targetX unit.y = unit.targetY - if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6)) { + if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)) { unit.mode = inTown ? 5 : 1 unit.moveMode = 'stop' - } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)) { + } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)) { unit.mode = 1 unit.moveMode = 'stop' } @@ -526,6 +526,17 @@ export class ClientWorld { } else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') { const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1) const uId = Number(ev.unitId ?? ev.id ?? 1) + const lifePct = ev.maxHp + ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) + : 128 + const defaultMode = + uType === 'monster' + ? lifePct <= 0 + ? 12 + : 1 + : uType === 'player' + ? 1 + : 0 ev = { type: 'UnitAssign', isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)), @@ -536,7 +547,8 @@ export class ClientWorld { name: ev.name ?? ev.code, x: Number(ev.x ?? 0), y: Number(ev.y ?? 0), - lifePct: ev.maxHp ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) : 128, + lifePct, + mode: ev.mode !== undefined ? Number(ev.mode) : defaultMode, }, } } else if (ev.type === 'EntityRemove') { @@ -753,7 +765,7 @@ export class ClientWorld { if (targetX !== unit.x || targetY !== unit.y) { unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) } - if (unit.unitType === 'player') { + if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { const inTown = isTownLevel(this.areaId) if (moveMode === 'stop') { unit.mode = inTown ? 5 : 1 @@ -764,16 +776,22 @@ export class ClientWorld { } else { unit.mode = inTown ? 6 : 2 } - } else if (unit.unitType === 'monster') { + if (moveMode !== 'stop') { + unit.castState = undefined + } + } else if (unit.unitType === 'monster' && unit.lifePct > 0 && unit.mode !== 0 && unit.mode !== 12) { if (moveMode === 'stop') { - if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { - unit.mode = 1 - } + unit.mode = 1 } else if (moveMode === 'run') { unit.mode = 15 + } else if (moveMode === 'knockback') { + unit.mode = 9 } else { unit.mode = 2 } + if (moveMode !== 'stop') { + unit.castState = undefined + } } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { @@ -816,6 +834,16 @@ export class ClientWorld { const uId = Number(ev.id ?? ev.unitId ?? 0) const unit = this.ensureUnit(ev.unitType, uId) unit.mode = ev.mode + if ( + ev.mode === 0 || + ev.mode === 1 || + ev.mode === 3 || + ev.mode === 12 || + ev.mode === 16 || + ev.mode === 17 + ) { + unit.castState = undefined + } if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) { unit.x = ev.at.x unit.y = ev.at.y diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index dbc74dc..0208243 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -14,11 +14,25 @@ function clampUnitType(raw: number): UnitType { } function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' { - // In D2Client.dll 0x6fac9830: 0x17 (0x0F run) / 0x18 (0x10 run) / 0x02 = run, 0x13/0x14/0x20/0x09 = knockback - if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x13 || rawMoveType === 0x14) { + // In D2Client.dll 0x6fac9830 (player) & 0x6fb82e68 (monster): + // - 0x17 (0x0F/0x67 run) / 0x18 (0x10/0x68 run) / 0x02 = run + // - 0x13/0x14 (player KB), 0x0e/0x0f (monster KB), 0x20/0x09 = knockback + if ( + rawMoveType === 0x20 || + rawMoveType === 0x09 || + rawMoveType === 0x0e || + rawMoveType === 0x0f || + rawMoveType === 0x13 || + rawMoveType === 0x14 + ) { return 'knockback' } - if (rawMoveType === 0x17 || rawMoveType === 0x18 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) { + if ( + rawMoveType === 0x17 || + rawMoveType === 0x18 || + rawMoveType === 0x02 || + (rawMoveType & 0x02) !== 0 + ) { return 'run' } return 'walk' diff --git a/src/netproto/d2gs/s2c/units.ts b/src/netproto/d2gs/s2c/units.ts index a99476c..2ed0d15 100644 --- a/src/netproto/d2gs/s2c/units.ts +++ b/src/netproto/d2gs/s2c/units.ts @@ -124,7 +124,68 @@ export function decode0x51AssignObject(pkt: Uint8Array): ServerEvent { } } -/** 0x59 AssignPlayer (26B, D2Client.dll 0x6fb5df20) */ +/** + * 1.13c D2Client.dll `0x6fb82e68` (`30` entries) mapping monster state-command byte (`pkt[5]` in + * `0x67`, `0x68`, `0x69`, `0x6A`, `0x6B`) to canonical `MonMode.txt` ID (`0..16`). + * Inverse of D2Game.dll `0x6fd1a718` (`g_aMonModePacketTable`). + */ +export const MONSTER_CMD_TO_MODE: readonly number[] = [ + 2, // 0x00: WL (0x68 walk) + 2, // 0x01: WL (0x67 walk) + 16, // 0x02: special + 16, // 0x03: special + 7, // 0x04: SC (Skill1 / Cast no target) + 7, // 0x05: SC (Skill1 / Cast with target) + 3, // 0x06: GH (Get Hit) + 1, // 0x07: NU (Neutral / 0x6D stop) + 0, // 0x08: DT (Death animation) + 12, // 0x09: DD (Dead corpse) + 4, // 0x0A: A1 (Attack 1 with target) + 4, // 0x0B: A1 (Attack 1 no target) + 8, // 0x0C: SQ (Sequence no target) + 8, // 0x0D: SQ (Sequence with target) + 9, // 0x0E: KB (Knockback no target) + 9, // 0x0F: KB (Knockback with target) + 5, // 0x10: A2 (Attack 2 with target) + 5, // 0x11: A2 (Attack 2 no target) + 6, // 0x12: BL (Block) + 16, // 0x13: special + 13, // 0x14: S2 (Skill 2) + 14, // 0x15: S3 (Skill 3) + 14, // 0x16: S3 (Skill 3) + 15, // 0x17: RN (0x67 run) + 15, // 0x18: RN (0x68 run) + 8, // 0x19: SQ (Sequence) + 10, // 0x1A: S1 (no target) + 10, // 0x1B: S1 (with target) + 11, // 0x1C: S2 (no target) + 11, // 0x1D: S2 (with target) +] + +function resolveMonsterCmdMode(rawCmd: number): number { + const mapped = MONSTER_CMD_TO_MODE[rawCmd] + return mapped !== undefined ? mapped : rawCmd +} + +/** + * In 1.13c (`D2Game.dll 0x6fd13620` & `D2Client.dll 0x6fafdc70`): + * - `pkt[10]` (`0x69`/`0x6B`) / `pkt[11]` (`0x6A`) carries `lifePct & 0x7f` ONLY when `rawCmd === 0x06` (`MONMODE_GETHIT`, `0x6fd13829` / `0x6fafe16c`). + * - When `rawCmd === 0x08` (`MONMODE_DEATH`) or `0x09` (`MONMODE_DEAD`), `D2Client.dll` (`0x6fafe298`) sets HP to `0`. + * - For all other commands (e.g. `0x0C` `MONMODE_SEQUENCE` when Charsi hammers the anvil), the byte is `0` on the wire and must NOT overwrite `unit.lifePct` with `0`. + */ +function resolveMonsterCmdLifePct(rawCmd: number, paramByte: number): number | undefined { + if (rawCmd === 0x08 || rawCmd === 0x09) { + return 0 + } + if (rawCmd === 0x06) { + if (paramByte === 0) return undefined + const masked = paramByte & 0x7f + return masked > 0 ? masked : 128 + } + return paramByte > 0 && paramByte <= 0x80 ? paramByte : undefined +} + +/** 0x59 AssignPlayer (26B, D2Client.dll 0x6fb5df20 -> 0x6fac9275) */ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x59 @@ -142,64 +203,72 @@ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { name, x, y, + mode: 1, }, isSelf: x !== 0 || y !== 0, } } -/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390) */ +/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390 -> 0x6fafdc70) */ export function decode0x69SetNpcMode(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x69 const id = r.u32LE() - const mode = r.u8() + const rawCmd = r.u8() const x = r.u16LE() const y = r.u16LE() - const lifePct = r.u8() - r.u8() + const paramByte = r.u8() + r.u8() // animSpeed + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), at: { x, y }, - lifePct, + ...(lifePct !== undefined ? { lifePct } : {}), } } -/** 0x6A NPCAction (12B, D2Client.dll 0x6fb5f340) */ +/** 0x6A NPCAction (12B, D2Client.dll 0x6fb5f340 -> 0x6fafdc70) */ export function decode0x6ANpcAction(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6A const id = r.u32LE() - const mode = r.u8() - r.u8() - r.u32LE() - r.u8() + const rawCmd = r.u8() + r.u8() // targetType + r.u32LE() // targetId + const paramByte = r.u8() + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), + ...(lifePct !== undefined ? { lifePct } : {}), } } -/** 0x6B NPCAction2 (16B, D2Client.dll 0x6fb5f7c0) */ +/** 0x6B NPCAction2 (16B, D2Client.dll 0x6fb5f7c0 -> 0x6fb5f390 -> 0x6fafdc70) */ export function decode0x6BNpcAction2(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6B const id = r.u32LE() - const mode = r.u8() - r.u32LE() + const rawCmd = r.u8() + r.u16LE() // targetX + r.u16LE() // targetY + const paramByte = r.u8() + r.u8() // animSpeed const x = r.u16LE() const y = r.u16LE() - r.u16LE() + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), at: { x, y }, + ...(lifePct !== undefined ? { lifePct } : {}), } } @@ -217,7 +286,15 @@ export function decode0x76UnitInSight(pkt: Uint8Array): ServerEvent { } } -/** 0xAC AssignNPC (variable pkt[12], D2Client.dll 0x6fb5e420) */ +/** + * 0xAC AssignNPC (variable pkt[12], D2Game.dll 0x6fcaca40 -> D2Client.dll 0x6fb5e420). + * + * Bitstream layout starting at offset 13 (`0x6fb5e462`): + * - 4 bits: initial monster mode `nMode` (`D2Game.dll 0x6fcacac9`: `0=DT`, `8=SQ`, `9=KB`, `12=DD`, + * or `1=NU` for all other alive modes; applied via `D2Common_11090` at `0x6fb5787c`/`0x6fafd345`) + * - 1 bit: `hasAnimComp` (`0x6fb5e47f`) + * - 1 bit: `hasMonsterFlags` (`0x6fb5e4fc`, followed by 5 boss flag bits when `hasAnimComp === 0`) + */ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0xAC @@ -228,27 +305,42 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { const lifePct = r.u8() const packetLen = r.u8() + let rawMode: number | undefined let flags = 0 if (packetLen > 13 && pkt.byteLength >= 13) { const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen))) - if (bitReader.remainingBits >= 6) { - bitReader.readBits(4) // initial facing direction - const hasAnimComp = bitReader.readBit() - // Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2). - // If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc: - if (hasAnimComp === 0 && bitReader.remainingBits >= 1) { - const hasMonsterFlags = bitReader.readBit() - if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) { - if (bitReader.readBit()) flags |= 0x04 // champion - if (bitReader.readBit()) flags |= 0x08 // unique - if (bitReader.readBit()) flags |= 0x02 // superunique - if (bitReader.readBit()) flags |= 0x10 // minion - if (bitReader.readBit()) flags |= 0x40 // ghostly + if (bitReader.remainingBits >= 4) { + rawMode = bitReader.readBits(4) // 4-bit initial monster mode (D2Client.dll 0x6fb5e462) + if (bitReader.remainingBits >= 1) { + const hasAnimComp = bitReader.readBit() + // Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2). + // If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc: + if (hasAnimComp === 0 && bitReader.remainingBits >= 1) { + const hasMonsterFlags = bitReader.readBit() + if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) { + if (bitReader.readBit()) flags |= 0x04 // champion + if (bitReader.readBit()) flags |= 0x08 // unique + if (bitReader.readBit()) flags |= 0x02 // superunique + if (bitReader.readBit()) flags |= 0x10 // minion + if (bitReader.readBit()) flags |= 0x40 // ghostly + } } } } } + const mode = + lifePct <= 0 + ? rawMode === 0 || rawMode === 12 + ? rawMode + : 12 + : rawMode !== undefined && rawMode > 0 && rawMode !== 12 + ? rawMode + : 1 + + // D2Client.dll 0x6fafd512..0x6fafd59c (D2CLIENT_InitMonsterUnit): initial facing seeded by unit id + const direction = Math.imul(id, 0x6ac690c5) & 0x3f + return { type: 'UnitAssign', unit: { @@ -257,6 +349,8 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { classId, x, y, + mode, + direction, lifePct, flags, }, diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 3c2f423..82581b7 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -678,4 +678,98 @@ describe('ClientWorld authoritative state mirror', () => { expect(world.containers.tradeGrid).toHaveLength(0) expect(world.containers.partnerTradeGrid).toHaveLength(0) }) + + it('Issue #551: initializes alive monsters/NPCs (e.g. Rogue Scout) in MONMODE_NEUTRAL (1) instead of MONMODE_DEATH (0) and transitions mode on UnitMove', () => { + const world = new ClientWorld() + + // Assign an alive camp Rogue Scout without an explicit mode field + world.apply({ + type: 'UnitAssign', + unit: { + unitType: 1, + id: 42, + classId: 564, // act1hire (Rogue Scout) + x: 5110, + y: 5060, + lifePct: 128, + }, + }) + const rogue = world.getUnit(1, 42)! + expect(rogue).toBeDefined() + expect(rogue.mode).toBe(1) // MONMODE_NEUTRAL (1), never MONMODE_DEATH (0) + + // Patrol walk (0x67 NPCMove) transitions mode to MONMODE_WALK (2) + world.apply({ + type: 'UnitMove', + unitType: 1, + id: 42, + mode: 'walk', + from: { x: 5110, y: 5060 }, + to: { x: 5116, y: 5064 }, + }) + expect(rogue.mode).toBe(2) + + // Patrol stop (0x6D NPCStop) transitions mode back to MONMODE_NEUTRAL (1) + world.apply({ + type: 'UnitMove', + unitType: 1, + id: 42, + mode: 'stop', + to: { x: 5116, y: 5064 }, + }) + expect(rogue.mode).toBe(1) + + // Dead monster spawned with lifePct=0 defaults to MONMODE_DEAD (12) + world.apply({ + type: 'UnitAssign', + unit: { + unitType: 1, + id: 99, + classId: 1, + x: 5200, + y: 5200, + lifePct: 0, + }, + }) + expect(world.getUnit(1, 99)?.mode).toBe(12) + }) + + it('Issue #551: live .d2cap Rogue Encampment captures never render alive camp NPCs (Rogue Scout, Akara, Charsi, Kashya, Warriv, Gheed) as corpses or mode=0 (DT)', async () => { + const { readFileSync } = await import('node:fs') + const { resolve } = await import('node:path') + const { OnlineSession } = await import('../../src/client/session/online-session.ts') + const { createSettingsStore } = await import('../../src/client/settings/settings-store.ts') + const { buildSceneDrawables } = await import('../../src/client/view/unit-drawables.ts') + + for (const fixture of [ + '02-d2gs-town-walk-chat.d2cap', + '03-d2gs-items-npc-skills.d2cap', + '05-d2gs-vendor-open.d2cap', + ]) { + const text = readFileSync(resolve('tests/fixtures/netproto', fixture), 'utf8') + const session = new OnlineSession({ settingsStore: createSettingsStore() }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + for (let i = 0; i < 30; i++) session.tick(40, 1000 + i * 40) + + const renderUnits = session.buildRenderableUnits() + const monsterUnits = renderUnits.filter(u => u.kind === 'monster') + expect(monsterUnits.length, `${fixture} should have camp NPCs`).toBeGreaterThan(0) + + for (const npc of monsterUnits) { + expect(npc.isCorpse, `${fixture} npc id=${npc.id} classId=${npc.classId}`).toBe(false) + expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(0) + expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(12) + } + + const scene = buildSceneDrawables(renderUnits) + const monsterDrawables = scene.unitDrawables.filter(u => u.unitKind === 'monster') + expect(monsterDrawables.length).toBe(monsterUnits.length) + for (const d of monsterDrawables) { + expect(d.isCorpse, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBe(false) + expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBeGreaterThan(0) + expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).not.toBe(12) + } + } + }) }) diff --git a/tests/netproto/d2gs-s2c.test.ts b/tests/netproto/d2gs-s2c.test.ts index ece8d34..0690eba 100644 --- a/tests/netproto/d2gs-s2c.test.ts +++ b/tests/netproto/d2gs-s2c.test.ts @@ -141,4 +141,116 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => { partnerId: 7, }) }) + + it('decodes 0xAC AssignNPC 4-bit bitstream mode (D2Game.dll 0x6fcacac9 / D2Client.dll 0x6fb5e462)', () => { + // Alive Rogue Scout (classId=564) with bitstream byte 0x01 (low 4 bits = 1 MONMODE_NEUTRAL) + const alivePkt = new Uint8Array([ + 0xac, + 0x2a, 0x00, 0x00, 0x00, // id = 42 + 0x34, 0x02, // classId = 564 (act1hire / Rogue Scout) + 0xf6, 0x13, // x = 5110 + 0xc4, 0x13, // y = 5060 + 0x80, // lifePct = 128 (full HP) + 0x0e, // packet length = 14 + 0x01, // bitstream: low 4 bits = 1 (MONMODE_NEUTRAL) + ]) + const aliveEv = decodeD2gsServerPacket(alivePkt) + expect(aliveEv.type).toBe('UnitAssign') + if (aliveEv.type === 'UnitAssign') { + expect(aliveEv.unit.unitType).toBe(1) + expect(aliveEv.unit.id).toBe(42) + expect(aliveEv.unit.classId).toBe(564) + expect(aliveEv.unit.lifePct).toBe(128) + expect(aliveEv.unit.mode).toBe(1) // MONMODE_NEUTRAL, never 0 (MONMODE_DEATH) + expect(aliveEv.unit.direction).toBeGreaterThanOrEqual(0) + expect(aliveEv.unit.direction).toBeLessThan(64) + } + + // Dead monster corpse (lifePct = 0, bitstream low 4 bits = 12 MONMODE_DEAD) + const corpsePkt = new Uint8Array([ + 0xac, + 0x99, 0x00, 0x00, 0x00, + 0x01, 0x00, + 0x00, 0x14, + 0x00, 0x14, + 0x00, // lifePct = 0 + 0x0e, + 0x0c, // low 4 bits = 12 (MONMODE_DEAD) + ]) + const corpseEv = decodeD2gsServerPacket(corpsePkt) + expect(corpseEv.type).toBe('UnitAssign') + if (corpseEv.type === 'UnitAssign') { + expect(corpseEv.unit.lifePct).toBe(0) + expect(corpseEv.unit.mode).toBe(12) + } + }) + + it('maps 0x69/0x6A/0x6B state command index via D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE)', () => { + // 0x69 SetNPCMode: [0x69, id:u32, cmd:u8, x:u16, y:u16, lifePct:u8, unk:u8] + const make69 = (cmd: number, lifePct: number) => { + const w = new ByteWriter(12) + w.u8(0x69) + w.u32LE(77) + w.u8(cmd) + w.u16LE(5100) + w.u16LE(5200) + w.u8(lifePct) + w.u8(0) + return decodeD2gsServerPacket(w.toUint8Array()) + } + + // cmd 0x07 -> mode 1 (MONMODE_NEUTRAL) + expect(make69(0x07, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 1, lifePct: 128 }) + // cmd 0x06 -> mode 3 (MONMODE_GETHIT) + expect(make69(0x06, 96)).toMatchObject({ type: 'UnitMode', id: 77, mode: 3, lifePct: 96 }) + // cmd 0x08 -> mode 0 (MONMODE_DEATH) + expect(make69(0x08, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 0, lifePct: 0 }) + // cmd 0x09 -> mode 12 (MONMODE_DEAD) + expect(make69(0x09, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 12, lifePct: 0 }) + // cmd 0x0A -> mode 4 (MONMODE_ATTACK1) + expect(make69(0x0a, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 4, lifePct: 128 }) + // cmd 0x10 -> mode 5 (MONMODE_ATTACK2) + expect(make69(0x10, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 5, lifePct: 128 }) + // cmd 0x04 -> mode 7 (MONMODE_CAST) + expect(make69(0x04, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 7, lifePct: 128 }) + + // Real 1.13c Charsi 0x6B NPCAction2 packet from 02-d2gs-town-walk-chat.d2cap: + // 6b 08000000 0c 000000000000 3f16 9a11 (rawCmd=0x0C Sequence, paramByte=0x00 must NOT zero out lifePct) + const charsi6b = new Uint8Array([ + 0x6b, + 0x08, 0x00, 0x00, 0x00, // id = 8 (Charsi) + 0x0c, // rawCmd = 0x0C -> mode 8 (MONMODE_SEQUENCE) + 0x00, 0x00, 0x00, 0x00, // targetX=0, targetY=0 + 0x00, 0x00, // paramByte=0, animSpeed=0 + 0x3f, 0x16, // x = 5695 + 0x9a, 0x11, // y = 4506 + ]) + const charsiEv = decodeD2gsServerPacket(charsi6b) + expect(charsiEv).toEqual({ + type: 'UnitMode', + unitType: 1, + id: 8, + mode: 8, + at: { x: 5695, y: 4506 }, + }) + }) + + it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => { + const w68Run = new ByteWriter(21) + w68Run.u8(0x68) + w68Run.u32LE(88) + w68Run.u8(0x18) // 0x18 = run in 0x68 NPCMoveToTarget + w68Run.u16LE(5100) + w68Run.u16LE(5200) + w68Run.u8(0) // targetType = player + w68Run.u32LE(1) // targetId = 1 + w68Run.bytes(new Uint8Array(6)) + const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array()) + expect(ev68).toMatchObject({ + type: 'UnitMove', + unitType: 1, + id: 88, + mode: 'run', + }) + }) })