fix(mcp,map): shift statstring[27] progression by 1 bit and skip [D2MissingArt] on 0-block DT1 tiles (#738, #739)

- Decode 5-bit character progression from MCP 0x19 33-byte statstring via
  (statBytes[27] >>> 1) & 0x1f per D2Game.dll CLIENTS_FillCharacterPreviewInfo
  (0x6FC32810) and Fog.#10085 FOG_Encode14BitsToString, fixing doubled
  progression values in parseMcpCharStatString and charSummaryToRosterCharacter.
- Do not trigger spurious [D2MissingArt] reports in LevelView enrichDraws or
  WorldRenderer wall drawing on legitimate blockCount === 0 collision/marker
  DT1 tiles (such as baallair\floor.dt1 tileIndex=109 in Worldstone Keep L2).
- Add regression tests for progression decoding and 136-level zero-missing-art
  LevelView construction.

Closes #738
Closes #739
This commit is contained in:
troytt 2026-10-03 11:01:41 +00:00
parent 72d413fa93
commit 33b3448fb0
10 changed files with 75 additions and 17 deletions

View File

@ -118,7 +118,7 @@ export interface RosterCharacter {
isExpansion: boolean
readonly isHardcore: boolean
readonly isLadder: boolean
/** 5-bit character progression (`0..15`) from MCP 33-byte `statString[27] & 0x1f` (`D2Launch.dll` 0x6fa4c887). */
/** 5-bit character progression (`0..15`) from MCP 33-byte `(statString[27] >>> 1) & 0x1f` (`Fog.#10085` / `D2Launch.dll` 0x6fa4c887). */
progression?: number | undefined
/** Optional explicit highest unlocked difficulty override. */
maxDifficulty?: GameDifficulty | undefined
@ -1997,7 +1997,7 @@ export function charSummaryToRosterCharacter(
statBytes.length > 27 &&
statBytes[27] !== 0xff
) {
progression = statBytes[27]! & 0x1f
progression = (statBytes[27]! >>> 1) & 0x1f
}
if (
progression === undefined ||

View File

@ -10,7 +10,7 @@
* - `[14..24]`: 11 component dye color indices (`0xFF` = uncolored, `0..20` = `Colors.txt` transform index)
* - `[25]`: character level (`1..99`)
* - `[26]`: status flags (`0x04` Hardcore, `0x08` Dead, `0x20` Expansion, `0x40` Ladder)
* - `[27]`: `actAndDiff` (`bits 0..4` = 5-bit progression)
* - `[27]`: `actAndDiff` (`(nPackedValue >>> 7) | 0x80`, `bits 1..5` = 5-bit progression)
*/
import type { CampfireClassId } from './flow-state.ts'

View File

@ -49,6 +49,7 @@ import type { PackedTilePlacement, TileAtlas } from './tile-atlas.ts'
export interface LevelDrawItem extends IsoDraw {
readonly dt1Path: string
readonly placement: PackedTilePlacement | null
readonly blockCount?: number | undefined
readonly animatedPlacements?: readonly (PackedTilePlacement | null)[]
}
@ -235,13 +236,15 @@ function enrichDraws(
)
}
const atlasEntry = tileAtlas?.getFrameByLibraryTile(dt1Path, draw.tile)
if (tileAtlas && (!atlasEntry || !atlasEntry.placement)) {
const libTile = libraries[draw.library]?.tiles[draw.tile]
const blockCount = atlasEntry?.blockCount ?? libTile?.blocks.length
if (tileAtlas && (!atlasEntry || (!atlasEntry.placement && blockCount !== 0))) {
reportMissingArt('tile', dt1Path, `Missing tile placement for tileIndex=${String(draw.tile)}`)
}
const placement = atlasEntry?.placement ?? null
let animatedPlacements: (PackedTilePlacement | null)[] | undefined
if (draw.animatedFrames && draw.animatedFrames.length > 1 && tileAtlas) {
const tile = libraries[draw.library]?.tiles[draw.tile]
const tile = libTile
if (tile && isAnimatedTile(tile)) {
const candidates = tileAtlas
.getFramesByStyleSeqType(dt1Path, tile.style, tile.sequence, tile.type)
@ -250,7 +253,7 @@ function enrichDraws(
.sort((a, b) => a.rarityFrameIndex - b.rarityFrameIndex || a.index - b.index)
if (candidates.length > 1) {
for (const c of candidates) {
if (!c.placement) {
if (!c.placement && c.blockCount > 0) {
reportMissingArt(
'tile',
dt1Path,
@ -266,6 +269,7 @@ function enrichDraws(
...draw,
dt1Path,
placement,
...(blockCount !== undefined ? { blockCount } : {}),
...(animatedPlacements !== undefined ? { animatedPlacements } : {}),
}
})

View File

@ -1098,7 +1098,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
const w = sortedWalls[wallIdx]!
if (w.sortY > maxSortY) break
const h = w.placement?.height ?? 40
if (!w.placement) {
if (!w.placement && w.blockCount !== 0) {
reportMissingArt(
'tile',
`wall:${w.cellX},${w.cellY}`,

View File

@ -41,6 +41,7 @@ export interface SceneTileQuad {
readonly tile: number
readonly dt1Path: string
readonly placement: PackedTilePlacement | null
readonly blockCount?: number | undefined
readonly animatedPlacements?: readonly (PackedTilePlacement | null)[] | null | undefined
}
@ -145,6 +146,7 @@ function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTi
tile: item.tile,
dt1Path: item.dt1Path,
placement: item.placement,
...(item.blockCount !== undefined ? { blockCount: item.blockCount } : {}),
animatedPlacements: item.animatedPlacements,
}
}
@ -330,16 +332,19 @@ export class ClientWorldSceneSource implements SceneSource {
const maxY = cameraTopLeft.y + profile.height + cullPaddingPx
for (const f of st.levelView.floors) {
if (!f.placement && f.blockCount === 0) continue
if (f.x >= minX && f.x <= maxX && f.y >= minY && f.y <= maxY) {
floorQuads.push(mapLevelDrawToQuad(f, actId))
}
}
for (const s of st.levelView.shadows) {
if (!s.placement && s.blockCount === 0) continue
if (s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) {
shadowQuads.push(mapLevelDrawToQuad(s, actId))
}
}
for (const w of st.levelView.walls) {
if (!w.placement && w.blockCount === 0) continue
if (w.x >= minX && w.x <= maxX && w.y >= minY && w.y <= maxY) {
const quad = mapLevelDrawToQuad(w, actId)
if (w.roofHeight !== undefined && w.roofHeight > 0) {

View File

@ -81,7 +81,8 @@ export type DecodedMcpS2cPacket =
* - `[11]`: `charClass + 1` (`1..7` -> `0..6`)
* - `[12]`: `charLevel` (`1..99`)
* - `[13]`: `charFlags` (`0x04` Hardcore, `0x08` Dead, `0x20` Expansion, `0x40` Ladder)
* - `[26]`: `actAndDiff`
* - `[26]`: `charFlags` low 7 bits | `0x80` (`0x04` Hardcore, `0x08` Dead, `0x20` Expansion, `0x40` Ladder)
* - `[27]`: `actAndDiff` (`(nPackedValue >>> 7) | 0x80`, where `bits 1..5` = 5-bit `nProgression`)
*/
export function parseMcpCharStatString(
name: string,
@ -103,12 +104,18 @@ export function parseMcpCharStatString(
const flags = isFull33 ? statBytes[26]! : statBytes.length > 13 ? statBytes[13]! : McpCharFlag.EXPANSION
const expansion = (flags & McpCharFlag.EXPANSION) !== 0
const actAndDiff = isFull33 && statBytes.length > 27 ? statBytes[27]! : statBytes.length > 26 ? statBytes[26]! : 0
// Per D2Launch.dll (0x6fa4c887..0x6fa4c910) & D2Multi.dll (0x6f9e5182..0x6f9e52cd):
// statBytes[27] & 0x1f is the 5-bit character progression (0..15, acts completed across Normal/Nightmare/Hell).
// Per D2Game.dll CLIENTS_FillCharacterPreviewInfo (0x6FC32810), Fog.#10085 FOG_Encode14BitsToString,
// and D2Launch.dll (0x6fa4c887..0x6fa4c910):
// D2PackedClientSaveFlags packs 5-bit nProgression at bits 8..12 (CLIENTSAVEFLAG_CHARACTER_PROGRESSION_BIT = 8).
// FOG_Encode14BitsToString splits the 14-bit value into two 7-bit bytes with 0x80 set:
// statBytes[26] = (nPackedValue & 0x7f) | 0x80
// statBytes[27] = ((nPackedValue >>> 7) & 0x7f) | 0x80
// Therefore bits 1..5 of statBytes[27] hold the 5-bit character progression: (statBytes[27] >>> 1) & 0x1f.
// Expansion (5 acts/difficulty): >= 10 -> Hell (2), >= 5 -> Nightmare (1), else Normal (0).
// Classic (4 acts/difficulty): >= 8 -> Hell (2), >= 4 -> Nightmare (1), else Normal (0).
const progression = actAndDiff & 0x1f
const act = ((actAndDiff & 0x07) % 5) + 1
const progression = (actAndDiff >>> 1) & 0x1f
const actsPerDiff = expansion ? 5 : 4
const act = (progression % actsPerDiff) + 1
const difficulty: 0 | 1 | 2 = expansion
? progression >= 10
? 2

View File

@ -44,6 +44,10 @@ import {
D2_BASE_WALK_SUBTILES_PER_SEC,
LocalMovementPredictor,
} from '../../src/client/motion/predictor.ts'
import {
clearMissingArtEntries,
getMissingArtEntries,
} from '../../src/client/scene/world-renderer.ts'
import { COLLIDE_MASK_INVALID } from '../../src/common/world/d2map.ts'
const TILES_PACK_DIR = path.resolve('public/act-packs/tiles')
@ -220,6 +224,7 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
const seed = 0x12345678
let verifiedLevels = 0
clearMissingArtEntries()
for (const range of actLevelRanges) {
mapService.loadAct({ act: range.act, seed, difficulty: 0 })
@ -264,6 +269,17 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
}
expect(verifiedLevels).toBe(136)
// Zero-block collision/marker DT1 tiles (e.g. baallair\floor.dt1 tileIndex=109 in Area 129)
// must not trigger spurious [D2MissingArt] reports (#739).
expect(getMissingArtEntries()).toEqual([])
const baalFloor109 = tileAtlas.getFrameByLibraryTile(
'data\\global\\tiles\\expansion\\baallair\\floor.dt1',
109,
)
expect(baalFloor109).toBeDefined()
expect(baalFloor109?.blockCount).toBe(0)
expect(baalFloor109?.placement).toBeNull()
}, 120_000)
it('round-trips world sub-tile, local sub-tile, and isometric ortho coordinates accurately', () => {

View File

@ -249,7 +249,7 @@ describe('Batch 7 & 8 Phase 3 — Frontend Renderer & Flow State Parity (#696–
equippedBytes[15] = 9 // TR dye -> cred (Crystal Red)
equippedBytes[25] = 85
equippedBytes[26] = 0xe0
equippedBytes[27] = 0x80 | 10
equippedBytes[27] = 0x80 | (10 << 1)
const equippedDesc = parseMcpAvatarStatstring(equippedBytes, 'pa')
expect(equippedDesc.hasEquippedComponents).toBe(true)

View File

@ -185,14 +185,24 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(getCharacterMaxDifficulty({ isExpansion: false, progression: 7 })).toBe('nightmare')
expect(getCharacterMaxDifficulty({ isExpansion: false, progression: 8 })).toBe('hell')
// Verify 33-byte MCP statString progression parsing (statBytes[27] & 0x1f)
// Verify 33-byte MCP statString progression parsing ((statBytes[27] >>> 1) & 0x1f per Fog.#10085)
const stat = new Uint8Array(33).fill(0xff)
stat[0] = 0x84
stat[1] = 0x80
stat[13] = 0x04 // Paladin (3 + 1)
stat[25] = 65
stat[26] = 0xe0 // EXPANSION | LADDER | 0x80
stat[27] = 0x80 | 5 // progression = 5 (Slayer, Nightmare unlocked)
stat[27] = 0x80 | (3 << 1) // 0x86: progression = 3 (Normal Act 3, must NOT unlock Nightmare)
const summaryNorm3 = parseMcpCharStatString('PalNorm3', 99999, stat)
expect(summaryNorm3.progression).toBe(3)
expect(summaryNorm3.difficulty).toBe(0)
const rosterNorm3 = charSummaryToRosterCharacter(summaryNorm3)
expect(isDifficultyUnlocked(rosterNorm3, 'normal')).toBe(true)
expect(isDifficultyUnlocked(rosterNorm3, 'nightmare')).toBe(false)
expect(isDifficultyUnlocked(rosterNorm3, 'hell')).toBe(false)
stat[27] = 0x80 | (5 << 1) // 0x8a: progression = 5 (Slayer, Nightmare unlocked, Hell locked)
const summaryNm = parseMcpCharStatString('PalSlayer', 99999, stat)
expect(summaryNm.progression).toBe(5)
expect(summaryNm.difficulty).toBe(1)
@ -201,12 +211,26 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => {
expect(isDifficultyUnlocked(rosterNm, 'nightmare')).toBe(true)
expect(isDifficultyUnlocked(rosterNm, 'hell')).toBe(false)
stat[27] = 0x80 | 10 // progression = 10 (Champion/Patriarch, Hell unlocked)
stat[27] = 0x80 | (10 << 1) // 0x94: progression = 10 (Champion, Hell unlocked)
const summaryHell = parseMcpCharStatString('PalChamp', 99999, stat)
expect(summaryHell.progression).toBe(10)
expect(summaryHell.difficulty).toBe(2)
const rosterHell = charSummaryToRosterCharacter(summaryHell)
expect(isDifficultyUnlocked(rosterHell, 'hell')).toBe(true)
stat[27] = 0x80 | (15 << 1) // 0x9e: progression = 15 (Patriarch, all difficulties unlocked)
const summaryPatriarch = parseMcpCharStatString('PalPat', 99999, stat)
expect(summaryPatriarch.progression).toBe(15)
expect(summaryPatriarch.difficulty).toBe(2)
const rosterPatriarch = charSummaryToRosterCharacter({
name: 'PalPatRaw',
classId: 3,
level: 80,
expansion: true,
rawStatString: stat,
})
expect(rosterPatriarch.progression).toBe(15)
expect(isDifficultyUnlocked(rosterPatriarch, 'hell')).toBe(true)
})
it('validates maxPlayers (1..8) and charDifference (1..99 when enabled) in Create Game', () => {

View File

@ -185,13 +185,15 @@ describe('BNCS & MCP framing, packets, and sessions', () => {
sorcStat[13] = 0x02 // Sorceress (1 + 1)
sorcStat[25] = 42 // level 42
sorcStat[26] = 0xe4 // 0x80 | LADDER (0x40) | EXPANSION (0x20) | HARDCORE (0x04)
sorcStat[27] = 0x80
sorcStat[27] = 0x80 | (15 << 1) // 0x9e: progression = 15 (bits 1..5 per Fog.#10085)
const summary = parseMcpCharStatString('TestSorc', 123456, sorcStat)
expect(summary.charClass).toBe(1)
expect(summary.level).toBe(42)
expect(summary.expansion).toBe(true)
expect(summary.hardcore).toBe(true)
expect(summary.ladder).toBe(true)
expect(summary.progression).toBe(15)
expect(summary.difficulty).toBe(2)
})
it('auto-echoes SID_PING in BncsSession and completes MCP startup over MemoryStream', async () => {