fix(tiles): 参照 1.13c DRLGROOM_LoadDt1Files 补充常驻全局通用 DT1 (InvisWal / Blank / Warp) (Issue #112)

参照 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
This commit is contained in:
troytt 2026-09-18 14:31:42 +00:00
parent 370dcc05b1
commit ed344259a1
2 changed files with 217 additions and 9 deletions

View File

@ -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 }
}

View File

@ -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)
})
})