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:
parent
72d413fa93
commit
33b3448fb0
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue