From ed344259a1a6ace52be967ee71cbd089e15954e4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 14:31:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(tiles):=20=E5=8F=82=E7=85=A7=201.13c=20DRLG?= =?UTF-8?q?ROOM=5FLoadDt1Files=20=E8=A1=A5=E5=85=85=E5=B8=B8=E9=A9=BB?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=80=9A=E7=94=A8=20DT1=20(InvisWal=20/=20Bl?= =?UTF-8?q?ank=20/=20Warp)=20(Issue=20#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参照 1.13c D2Common.dll (DRLGROOM_LoadDt1Files @ 0x6fdb8400),在加载完 LvlTypes.txt 后无条件追加 3 个全局常驻通用 DT1: - data\global\tiles\Act1\Outdoors\Blank.dt1 - data\global\tiles\Act1\Barracks\InvisWal.dt1 - data\global\tiles\Act1\Barracks\Warp.dt1 其中 InvisWal.dt1 包含 style=49 sequence=7 的地面阻挡瓦片 (walk-blocking collision invisible wall),解决了 Act 5 Hell 1 (125) 和 Pandemonium 3 (135) 等全地图漫游中报 missingTile: floor style=49 sequence=7:0 的问题。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/acts.ts | 29 +++-- tests/global-dt1-load.test.ts | 197 ++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 tests/global-dt1-load.test.ts diff --git a/src/game/acts.ts b/src/game/acts.ts index d3f5b02..d76f288 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -290,6 +290,20 @@ export function tileMemberPath(relative: string): string { return `${TILES}${relative.replaceAll('/', '\\')}` } +/** + * Universal DT1 files unconditionally appended by D2Common.dll + * (DRLGROOM_LoadDt1Files @ 0x6fdb8400) after loading LvlTypes.txt. + * + * Blank.dt1: blank placeholder tiles + * InvisWal.dt1: invisible walls, including style=49 sequence=7 floor tile (walk-blocking collision) + * Warp.dt1: generic warp tiles + */ +export const UNIVERSAL_DT1S: readonly string[] = [ + tileMemberPath('Act1/Outdoors/Blank.dt1'), + tileMemberPath('Act1/Barracks/InvisWal.dt1'), + tileMemberPath('Act1/Barracks/Warp.dt1'), +] + /** * Load the tables from a mounted stack. * @@ -359,15 +373,7 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number): const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask')) const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0 const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined - const dt1Names: string[] = [] - for (let slot = 1; slot <= 32; slot += 1) { - const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`) - if (value === '' || value === '0') continue - // The mask is indexed by the slot's position in the file list, not by the - // position of the surviving entries. - if ((dt1Mask & (1 << (slot - 1))) === 0) continue - dt1Names.push(tileMemberPath(value)) - } + const { dt1Names } = resolveLevelLibraries(tables, levelId) const ds1Names: string[] = [] for (const presetRow of preset) { @@ -479,6 +485,11 @@ export function resolveLevelLibraries(tables: ActTables, levelId: number): Level // when assembling Act 5 siege DS1 files which reference temptile.dt1. dt1Names.push(tileMemberPath('Expansion/Siege/temptile.dt1')) } + for (const universal of UNIVERSAL_DT1S) { + if (!dt1Names.some(existing => existing.toLowerCase() === universal.toLowerCase())) { + dt1Names.push(universal) + } + } return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask } } diff --git a/tests/global-dt1-load.test.ts b/tests/global-dt1-load.test.ts new file mode 100644 index 0000000..4b76866 --- /dev/null +++ b/tests/global-dt1-load.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import * as fs from 'node:fs' +import { + loadActTables, + resolveLevel, + resolveLevelLibraries, + UNIVERSAL_DT1S, +} from '../src/game/acts.ts' +import type { ActTables, D2Table } from '../src/game/acts.ts' +import { buildIsoMapScene } from '../src/game/d2map.ts' +import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' +import { decodeDt1 } from '../src/formats/dt1.ts' +import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' + +function createMockTables(): ActTables { + const emptyTable: D2Table = { header: [], rows: [] } + const levels: D2Table = { + header: ['Id', 'Name', 'Pal', 'LevelType', 'DrlgType', 'SizeX', 'SizeY', 'Act', 'Waypoint', 'Position', 'Portal'], + rows: [ + ['1', 'Act 1 - Town', '0', '1', '2', '56', '40', '0', '255', '0', '0'], + ['79', 'Act 3 - Kurast 1', '2', '22', '3', '100', '100', '2', '79', '0', '0'], + ['125', 'Act 5 - Hell 1', '4', '35', '1', '80', '80', '4', '255', '0', '0'], + ['135', 'Act 5 - Pandemonium 3', '4', '35', '1', '80', '80', '4', '255', '0', '0'], + ], + } + const lvltypes: D2Table = { + header: ['Id', 'Name', 'File 1', 'File 2'], + rows: [ + ['1', 'Act 1 - Town', 'Act1/Town/Floor.dt1', 'Act1/Town/Walls.dt1'], + ['22', 'Act 3 - Jungle', 'Act3/Jungle/Floor.dt1', 'Act3/Jungle/Tree.dt1'], + ['35', 'Act 5 - Lava', 'Act4/Lava/Floor.dt1', 'Act4/Lava/Walls.dt1'], + ], + } + const lvlprest: D2Table = { + header: ['Def', 'LevelId', 'File1', 'Dt1Mask', 'Animate'], + rows: [ + ['1', '1', 'TownN1.ds1', '3', '80'], + ], + } + return { + levels, + lvltypes, + lvlprest, + monstats: emptyTable, + monpreset: emptyTable, + monstats2: emptyTable, + monlvl: emptyTable, + montype: emptyTable, + monumod: emptyTable, + superuniques: emptyTable, + lvlwarp: emptyTable, + } +} + +function makeSingleTileDs1(style: number, sequence: number, prop1 = 1): Ds1 { + const width = 35 + const height = 25 + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + if (x === 30 && y === 18) { + row.push({ + walls: [], + floors: [{ prop1, style, sequence, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } else { + row.push({ walls: [], floors: [], shadows: [], substitutions: [] }) + } + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 5, + substitutionType: 0, + wallLayers: 0, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } +} + +function createMockInvisWalDt1(): Dt1 { + const dummyFlags = Array.from({ length: 25 }, () => ({ + blockWalk: true, + blockLos: false, + blockJump: false, + blockPlayerWalk: false, + blockLight: false, + raw: 1, + })) + const mockTile: Dt1Tile = { + direction: 0, + height: -80, + width: 160, + type: 0, // Floor + style: 49, + sequence: 7, + materialFlags: 0, + roofHeight: 0, + animated: 0, + rarityFrameIndex: 0, + subTileFlags: dummyFlags, + minBlockY: 0, + bitmapHeight: 80, + blocks: [], + } + return { + versionMajor: 7, + versionMinor: 6, + warnings: [], + tiles: [mockTile], + } +} + +describe('Blizzard 1.13c DRLGROOM_LoadDt1Files Universal DT1s (Issue #112)', () => { + it('exports UNIVERSAL_DT1S with exact Blizzard paths and casing', () => { + expect(UNIVERSAL_DT1S).toEqual([ + 'data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1', + 'data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1', + 'data\\global\\tiles\\Act1\\Barracks\\Warp.dt1', + ]) + }) + + it('unconditionally appends universal DT1s in resolveLevelLibraries for all test levels', () => { + const mockTables = createMockTables() + for (const levelId of [1, 79, 125, 135]) { + const { dt1Names } = resolveLevelLibraries(mockTables, levelId) + expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1') + expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1') + expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\Warp.dt1') + } + }) + + it('unconditionally appends universal DT1s in resolveLevel', () => { + const mockTables = createMockTables() + const levelInfo = resolveLevel(mockTables, 1) + expect(levelInfo.dt1Names).toContain('data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1') + expect(levelInfo.dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1') + expect(levelInfo.dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\Warp.dt1') + }) + + it('recognizes floor style=49 sequence=7 when InvisWal.dt1 is in the pool and flags missing without it', () => { + const ds1 = makeSingleTileDs1(49, 7) + const sceneWithout = buildIsoMapScene(ds1, []) + expect(sceneWithout.missingTiles).toBe(1) + expect(sceneWithout.missingRefs[0]).toContain('floor style=49 sequence=7:0 @cell(30,18)') + + const mockDt1 = createMockInvisWalDt1() + const sceneWith = buildIsoMapScene(ds1, [mockDt1]) + expect(sceneWith.missingTiles).toBe(0) + expect(sceneWith.floors).toHaveLength(1) + expect(sceneWith.floors[0]!.cellX).toBe(30) + expect(sceneWith.floors[0]!.cellY).toBe(18) + }) + + const hasRealData = fs.existsSync('samples/d2/d2data.mpq') + it.runIf(hasRealData)('verifies real MPQ tables and InvisWal.dt1 decoding on levels 1, 79, 125, 135', async () => { + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + try { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } catch {} + } + const realTables = await loadActTables(archives) + + for (const levelId of [1, 79, 125, 135]) { + const { dt1Names } = resolveLevelLibraries(realTables, levelId) + expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1') + expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1') + expect(dt1Names).toContain('data\\global\\tiles\\Act1\\Barracks\\Warp.dt1') + } + + // Decode real InvisWal.dt1 from MPQ and ensure style=49 sequence=7 floor tile is present + const invisWalBytes = await archives.read('data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1') + const realInvisWal = decodeDt1(invisWalBytes) + const match = realInvisWal.tiles.find(t => t.type === 0 && t.style === 49 && t.sequence === 7) + expect(match).toBeDefined() + expect(match?.style).toBe(49) + expect(match?.sequence).toBe(7) + + // Build scene with real InvisWal.dt1 + const ds1 = makeSingleTileDs1(49, 7) + const scene = buildIsoMapScene(ds1, [realInvisWal]) + expect(scene.missingTiles).toBe(0) + expect(scene.floors).toHaveLength(1) + }) +})