diff --git a/scripts/verify-collision-orientation.ts b/scripts/verify-collision-orientation.ts new file mode 100644 index 0000000..2123b8b --- /dev/null +++ b/scripts/verify-collision-orientation.ts @@ -0,0 +1,204 @@ +// verify-collision-orientation.ts — 永久断言 DT1 子格阻挡标志位的行序与 OpenDiablo2 / D2MOO 完全一致 +// +// 事故与背景(Issue #4): +// DT1 每张瓦片记录末尾有 25 个字节的子格碰撞标志。在原始文件里是「自下而上」存储的: +// 文件顺序的第 0..4 字节是这一格 5×5 足迹的最下面一行(subY=4),第 20..24 字节是最上面一行(subY=0)。 +// 之前解码器按 0..24 顺序存入 subTileFlags,导致每个格子内部的阻挡带被上下镜像。 +// +// 两个官方/引擎逆向实现互相印证: +// 1. OpenDiablo2 `reference/opendiablo2/d2core/d2map/d2mapengine/map_tile.go:17`: +// var subtileLookup = [5][5]int{ +// {20, 21, 22, 23, 24}, // 房间子格 (x, 0) —— 最上面一行,读文件 20..24 +// {15, 16, 17, 18, 19}, +// {10, 11, 12, 13, 14}, +// {5, 6, 7, 8, 9}, +// {0, 1, 2, 3, 4}, // 最下面一行,读文件 0..4 +// } +// 2. D2MOO `D2Collision.cpp:93 sub_6FD411F0`: +// `pTmp = &v5[5 * (nY - nCappedY + 4) - nX];` 配合循环 `pTmp -= 5;`,对房间第 r 行取文件第 (4-r) 行。 +// +// 本脚本作为永久验收测试,确保: +// 1. 解码后的 `subTileFlags[subY * 5 + subX].raw` 严格等于 OpenDiablo2 的 `subtileLookup[subY][subX]`; +// 2. 严格等于 D2MOO 的 `(4 - subY) * 5 + subX` 逆向公式; +// 3. 只有最下行阻挡的文件(0..4=1),解出后只在 subY=4 阻挡; +// 4. 只有最上行阻挡的文件(20..24=1),解出后只在 subY=0 阻挡。 +// +// 运行:npx tsx scripts/verify-collision-orientation.ts + +import { decodeDt1, SUB_TILE_GRID } from '../src/formats/dt1.ts' + +/** + * OpenDiablo2 canonical 5×5 lookup table. + * `reference/opendiablo2/d2core/d2map/d2mapengine/map_tile.go:17` + */ +const OD2_SUBTILE_LOOKUP = [ + [20, 21, 22, 23, 24], // 房间子格 (x, 0) —— 最上面一行,读文件 20..24 + [15, 16, 17, 18, 19], + [10, 11, 12, 13, 14], + [5, 6, 7, 8, 9], + [0, 1, 2, 3, 4], // 房间子格 (x, 4) —— 最下面一行,读文件 0..4 +] as const + +/** + * D2MOO canonical formula: `(4 - subY) * 5 + subX`. + */ +function d2mooLookup(subX: number, subY: number): number { + return (4 - subY) * 5 + subX +} + +/** + * 构造一个仅包含头部和一个瓦片记录的最小合法 DT1 二进制字节数组。 + * 瓦片的 25 字节 subTileFlags 按其在文件中的偏移填入 0..24。 + */ +function createSyntheticDt1(customFlags?: (fileIndex: number) => number): Uint8Array { + const tileDataStart = 276 + const tileRecordSize = 96 + const blockHeaderSize = 20 + const blockDataStart = tileDataStart + tileRecordSize + blockHeaderSize + const totalLength = blockDataStart + 32 + const buf = new Uint8Array(totalLength) + const view = new DataView(buf.buffer) + + view.setInt32(0, 7, true) + view.setInt32(4, 6, true) + view.setInt32(268, 1, true) // 1 tile + view.setInt32(272, tileDataStart, true) + + const at = tileDataStart + view.setInt32(at + 0, 0, true) // direction + view.setInt16(at + 4, 0, true) // roof height + view.setUint16(at + 6, 0, true) // material flags + view.setInt32(at + 8, -80, true) // height + view.setInt32(at + 12, 160, true) // width + view.setInt32(at + 20, 0, true) // type + view.setInt32(at + 24, 1, true) // style + view.setInt32(at + 28, 0, true) // sequence + view.setInt32(at + 32, 0, true) // rarity + + // 25 字节 flags 在 at + 40 + for (let i = 0; i < 25; i += 1) { + buf[at + 40 + i] = customFlags ? customFlags(i) : i + } + + const blockHeaderPointer = tileDataStart + tileRecordSize + view.setInt32(at + 72, blockHeaderPointer, true) + view.setInt32(at + 76, blockHeaderSize, true) + view.setInt32(at + 80, 1, true) // 1 block + + // Block header + view.setInt16(blockHeaderPointer + 0, 0, true) // x + view.setInt16(blockHeaderPointer + 2, 0, true) // y + buf[blockHeaderPointer + 6] = 0 // gridX + buf[blockHeaderPointer + 7] = 0 // gridY + view.setInt16(blockHeaderPointer + 8, 0, true) // format (RLE) + view.setInt32(blockHeaderPointer + 10, 4, true) // length + view.setInt32(blockHeaderPointer + 16, blockDataStart - blockHeaderPointer, true) + + return buf +} + +const checks: { name: string; ok: boolean; detail: string }[] = [] + +function check(name: string, ok: boolean, detail: string): void { + checks.push({ name, ok, detail }) + console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`) +} + +function main(): void { + console.log('== DT1 Collision Orientation Verification ==\n') + + // 1. OD2 lookup table vs D2MOO formula equivalence + let od2EqualsD2moo = true + for (let subY = 0; subY < 5; subY += 1) { + for (let subX = 0; subX < 5; subX += 1) { + if (OD2_SUBTILE_LOOKUP[subY][subX] !== d2mooLookup(subX, subY)) { + od2EqualsD2moo = false + } + } + } + check( + 'reference parity', + od2EqualsD2moo, + 'OpenDiablo2 subtileLookup and D2MOO formula (4-subY)*5+subX produce identical indices for all 25 sub-tiles', + ) + + // 2. Decode synthetic DT1 with sequential file bytes 0..24 + const dt1Bytes = createSyntheticDt1() + const decoded = decodeDt1(dt1Bytes) + check('synthetic decode', decoded.tiles.length === 1, 'decoded 1 synthetic tile') + + const tile = decoded.tiles[0]! + let mismatchCount = 0 + const mismatches: string[] = [] + + for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) { + for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) { + const actualRaw = tile.subTileFlags[subY * SUB_TILE_GRID + subX]?.raw + const expectedFileIndex = OD2_SUBTILE_LOOKUP[subY][subX] + if (actualRaw !== expectedFileIndex) { + mismatchCount += 1 + mismatches.push(`subTile(${subX}, ${subY}): actual=${String(actualRaw)}, expected=${String(expectedFileIndex)}`) + } + } + } + + check( + 'row order matches OD2 subtileLookup', + mismatchCount === 0, + mismatchCount === 0 + ? 'all 25 sub-tiles mapped from file index to room grid row-order correctly' + : `${String(mismatchCount)} sub-tiles mismatched: ${mismatches.join(', ')}`, + ) + + // 3. Verify top row (subY = 0) specifically gets file bytes 20..24 + const topRowActual = [0, 1, 2, 3, 4].map(x => tile.subTileFlags[0 * 5 + x]?.raw) + const topRowExpected = [20, 21, 22, 23, 24] + check( + 'top row mapping (subY=0)', + JSON.stringify(topRowActual) === JSON.stringify(topRowExpected), + `room row 0 (top) reads file bytes [${topRowActual.join(', ')}], expected [${topRowExpected.join(', ')}]`, + ) + + // 4. Verify bottom row (subY = 4) specifically gets file bytes 0..4 + const bottomRowActual = [0, 1, 2, 3, 4].map(x => tile.subTileFlags[4 * 5 + x]?.raw) + const bottomRowExpected = [0, 1, 2, 3, 4] + check( + 'bottom row mapping (subY=4)', + JSON.stringify(bottomRowActual) === JSON.stringify(bottomRowExpected), + `room row 4 (bottom) reads file bytes [${bottomRowActual.join(', ')}], expected [${bottomRowExpected.join(', ')}]`, + ) + + // 5. Semantic directional test: only file bottom row (0..4) blocked -> decoded subY=4 must block, subY=0 must NOT block + const fileBottomBlockedDt1 = createSyntheticDt1(fileIdx => (fileIdx < 5 ? 1 : 0)) + const decodedBottomBlocked = decodeDt1(fileBottomBlockedDt1).tiles[0]! + const subY4Blocked = [0, 1, 2, 3, 4].every(x => decodedBottomBlocked.subTileFlags[4 * 5 + x]?.blockWalk === true) + const subY0Unblocked = [0, 1, 2, 3, 4].every(x => decodedBottomBlocked.subTileFlags[0 * 5 + x]?.blockWalk === false) + check( + 'file bottom row is room bottom row', + subY4Blocked && subY0Unblocked, + `file bytes 0..4 blocked -> decoded subY=4 blocked: ${String(subY4Blocked)}, subY=0 unblocked: ${String(subY0Unblocked)}`, + ) + + // 6. Semantic directional test: only file top row (20..24) blocked -> decoded subY=0 must block, subY=4 must NOT block + const fileTopBlockedDt1 = createSyntheticDt1(fileIdx => (fileIdx >= 20 ? 1 : 0)) + const decodedTopBlocked = decodeDt1(fileTopBlockedDt1).tiles[0]! + const subY0Blocked = [0, 1, 2, 3, 4].every(x => decodedTopBlocked.subTileFlags[0 * 5 + x]?.blockWalk === true) + const subY4Unblocked = [0, 1, 2, 3, 4].every(x => decodedTopBlocked.subTileFlags[4 * 5 + x]?.blockWalk === false) + check( + 'file top row is room top row', + subY0Blocked && subY4Unblocked, + `file bytes 20..24 blocked -> decoded subY=0 blocked: ${String(subY0Blocked)}, subY=4 unblocked: ${String(subY4Unblocked)}`, + ) + + console.log() + const failures = checks.filter(c => !c.ok) + if (failures.length === 0) { + console.log(`ALL ${String(checks.length)} CHECKS PASSED — DT1 sub-tile orientation verified against OpenDiablo2 and D2MOO`) + process.exit(0) + } else { + console.error(`${String(failures.length)} of ${String(checks.length)} CHECKS FAILED`) + process.exit(1) + } +} + +main() diff --git a/src/formats/dt1.ts b/src/formats/dt1.ts index 9a550bd..de44521 100644 --- a/src/formats/dt1.ts +++ b/src/formats/dt1.ts @@ -31,7 +31,7 @@ const BLOCK_PIXEL_HEIGHT = 32 /** Bytes per block header. */ const BLOCK_HEADER_SIZE = 20 /** Sub-tile grid edge (5×5 collision flags per tile). */ -const SUB_TILE_GRID = 5 +export const SUB_TILE_GRID = 5 /** Bytes of one isometric block. */ const ISOMETRIC_BLOCK_SIZE = 256 /** Row offsets of the isometric diamond, one entry per row. */ @@ -286,8 +286,16 @@ export function decodeDt1(data: Uint8Array): Dt1 { for (let index = 0; index < tileCount; index += 1) { const at = tileDataStart + index * TILE_RECORD_SIZE const subTileFlags: SubTileFlags[] = [] - for (let sub = 0; sub < SUB_TILE_GRID * SUB_TILE_GRID; sub += 1) { - subTileFlags.push(subTileFlagsOf(data[at + 40 + sub]!)) + for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) { + for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) { + // DT1 sub-tile collision flags are stored bottom-to-top in the binary + // (bytes 0..4 are the bottom row subY=4, bytes 20..24 are the top row subY=0). + // OpenDiablo2 subtileLookup: row 0 -> 20..24, row 4 -> 0..4. + // D2MOO D2Collision.cpp: pTmp = &v5[5 * (nY - nCappedY + 4) - nX]. + // Normalize into top-to-bottom row order [subY * 5 + subX]. + const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX + subTileFlags.push(subTileFlagsOf(data[at + 40 + fileIndex]!)) + } } const blockHeaderPointer = i32(data, at + 72) const blockHeaderSize = i32(data, at + 76) diff --git a/src/game/character.ts b/src/game/character.ts index bdb3025..fa24a9b 100644 --- a/src/game/character.ts +++ b/src/game/character.ts @@ -151,6 +151,7 @@ export async function loadCharacterSheet( const groups: { frames: SpriteFrame[] }[] = [] let skipped = 0 for (let direction = 0; direction < cof.numberOfDirections; direction += 1) { + const dir64 = Math.round((direction * 64) / cof.numberOfDirections) const frames: SpriteFrame[] = [] for (let index = 0; index < cof.framesPerDirection; index += 1) { // Union box across the layers that have art for this direction, so every @@ -163,7 +164,8 @@ export async function loadCharacterSheet( for (let layer = 0; layer < cof.layers.length; layer += 1) { const sprite = sprites[layer] if (sprite === null || sprite === undefined) continue - const layerDirection = sprite.directions[direction % sprite.directions.length] + const dccDir = dir64ToDcc(dir64, sprite.directions.length) + const layerDirection = sprite.directions[dccDir] const frame = layerDirection?.frames[index] if (frame === undefined) { skipped += 1; continue } left = Math.min(left, layerDirection!.box.left) @@ -191,7 +193,8 @@ export async function loadCharacterSheet( .filter(entry => entry.sprite !== null && entry.sprite !== undefined) for (const entry of ordered) { const sprite = entry.sprite as DccFile - const layerDirection = sprite.directions[direction % sprite.directions.length] + const dccDir = dir64ToDcc(dir64, sprite.directions.length) + const layerDirection = sprite.directions[dccDir] const frame = layerDirection?.frames[index] if (frame === undefined) continue blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop) @@ -263,17 +266,68 @@ export function dir64ToCof(direction: number, directions: number): number { } /** - * Map a screen facing to a COF direction. + * The engine's 64-direction space → DCC direction tables. * - * The page's input is eight screen directions starting north, which in the - * engine's 64-direction space are the eight multiples of 8 — so this is - * {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the - * engine's terms means the result stays right if the facing ever gets finer - * (mouse aiming, 16-way input), where the plain "two steps per facing" reading - * would not: with eight facings the two agree, which - * `npm run verify:dcc` checks for every direction count. + * Ported from OpenDiablo2 `d2fileformats/d2dcc/dcc_dir_lookup.go` (`Dir64ToDcc`), + * which reproduces the engine's internal DCC direction layout. Unlike COF files + * (which index directions sequentially along a circle), DCC files store directions + * in a hierarchical / permuted order (e.g. 16 directions: 4, 8, 0, 9, 5, 10, ...). + */ +const DIR64_TO_DCC: Readonly> = { + 4: [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, + ], + 8: [ + 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, + 5, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, + 6, 6, 6, 6, 2, 2, 2, 2, 2, 2, 2, 2, 7, 7, 7, 7, + 7, 7, 7, 7, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, + ], + 16: [ + 4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5, + 5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6, + 6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7, + 7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4, + ], + 32: [ + 4, 16, 16, 8, 8, 17, 17, 0, 0, 18, 18, 9, 9, 19, 19, 5, + 5, 20, 20, 10, 10, 21, 21, 1, 1, 22, 22, 11, 11, 23, 23, 6, + 6, 24, 24, 12, 12, 25, 25, 2, 2, 26, 26, 13, 13, 27, 27, 7, + 7, 28, 28, 14, 14, 29, 29, 3, 3, 30, 30, 15, 15, 31, 31, 4, + ], + 64: [ + 4, 32, 16, 33, 8, 34, 17, 35, 0, 36, 18, 37, 9, 38, 19, 39, + 5, 40, 20, 41, 10, 42, 21, 43, 1, 44, 22, 45, 11, 46, 23, 47, + 6, 48, 24, 49, 12, 50, 25, 51, 2, 52, 26, 53, 13, 54, 27, 55, + 7, 56, 28, 57, 14, 58, 29, 59, 3, 60, 30, 61, 15, 62, 31, 63, + ], +} + +/** + * Map a 64-direction space direction to a DCC direction, the way the engine does. * - * @param facing - 0 = north, clockwise, 0..7. + * @param direction - 0..63. + * @param directions - directions in the DCC (4, 8, 16, 32 or 64). + * @returns the DCC direction index, 0 when the count is not one the engine uses. + */ +export function dir64ToDcc(direction: number, directions: number): number { + const table = DIR64_TO_DCC[directions] + if (table === undefined) return 0 + const index = ((Math.trunc(direction) % 64) + 64) % 64 + return table[index] ?? 0 +} + +/** + * Map a facing (0 = south, turning west: 0..7) to a COF direction. + * + * The game's eight facings start at south and turn clockwise + * (South = 0, SouthWest = 1, West = 2, NorthWest = 3, North = 4, NorthEast = 5, East = 6, SouthEast = 7), + * which in the engine's 64-direction space are the eight multiples of 8. + * + * @param facing - 0 = south, turning west, 0..7. * @param directions - directions in the COF. * @returns the direction index. */ diff --git a/tools/go-oracle/main.go b/tools/go-oracle/main.go index cb23c06..fec93f6 100644 --- a/tools/go-oracle/main.go +++ b/tools/go-oracle/main.go @@ -103,24 +103,28 @@ func dumpDt1(data []byte) (any, error) { tiles := []any{} for _, tile := range library.Tiles { flags := []any{} - for _, flag := range tile.SubTileFlags { - var raw byte - if flag.BlockWalk { - raw |= 1 + for subY := 0; subY < 5; subY++ { + for subX := 0; subX < 5; subX++ { + fileIndex := (4-subY)*5 + subX + flag := tile.SubTileFlags[fileIndex] + var raw byte + if flag.BlockWalk { + raw |= 1 + } + if flag.BlockLOS { + raw |= 2 + } + if flag.BlockJump { + raw |= 4 + } + if flag.BlockPlayerWalk { + raw |= 8 + } + if flag.BlockLight { + raw |= 32 + } + flags = append(flags, int(raw)) } - if flag.BlockLOS { - raw |= 2 - } - if flag.BlockJump { - raw |= 4 - } - if flag.BlockPlayerWalk { - raw |= 8 - } - if flag.BlockLight { - raw |= 32 - } - flags = append(flags, int(raw)) } blocks := []any{} for _, block := range tile.Blocks {