diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index 02a7158..2ad8ea2 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -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 || diff --git a/src/client/frontend/statstring-avatar.ts b/src/client/frontend/statstring-avatar.ts index bac4531..10c9c08 100644 --- a/src/client/frontend/statstring-avatar.ts +++ b/src/client/frontend/statstring-avatar.ts @@ -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' diff --git a/src/client/map/level-view.ts b/src/client/map/level-view.ts index e1ef595..a07d0ca 100644 --- a/src/client/map/level-view.ts +++ b/src/client/map/level-view.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 } : {}), } }) diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index f6d4686..0e12d70 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -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}`, diff --git a/src/client/view/scene-source.ts b/src/client/view/scene-source.ts index 648a604..575eaed 100644 --- a/src/client/view/scene-source.ts +++ b/src/client/view/scene-source.ts @@ -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) { diff --git a/src/netproto/mcp/packets.ts b/src/netproto/mcp/packets.ts index b2a1d9d..9fb89ab 100644 --- a/src/netproto/mcp/packets.ts +++ b/src/netproto/mcp/packets.ts @@ -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 diff --git a/tests/client/drlg-map-motion.test.ts b/tests/client/drlg-map-motion.test.ts index cec915f..15d4e35 100644 --- a/tests/client/drlg-map-motion.test.ts +++ b/tests/client/drlg-map-motion.test.ts @@ -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', () => { diff --git a/tests/client/frontend-batch7-batch8.test.ts b/tests/client/frontend-batch7-batch8.test.ts index 67b379c..29928ab 100644 --- a/tests/client/frontend-batch7-batch8.test.ts +++ b/tests/client/frontend-batch7-batch8.test.ts @@ -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) diff --git a/tests/frontend/flow-state.test.ts b/tests/frontend/flow-state.test.ts index 34113d8..e47fa32 100644 --- a/tests/frontend/flow-state.test.ts +++ b/tests/frontend/flow-state.test.ts @@ -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', () => { diff --git a/tests/netproto/core-crypto-bncs-mcp.test.ts b/tests/netproto/core-crypto-bncs-mcp.test.ts index ecc621c..3a36b43 100644 --- a/tests/netproto/core-crypto-bncs-mcp.test.ts +++ b/tests/netproto/core-crypto-bncs-mcp.test.ts @@ -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 () => {