fix(drlg,pack): pack XX minion art, fix L123 var3 waypoint, and pin exact DRLG tile variants (#500, #501, #502)

This commit is contained in:
troytt 2026-09-28 09:23:12 +00:00
parent 298c6f6b39
commit 909c50eb48
17 changed files with 289 additions and 73 deletions

View File

@ -31,9 +31,9 @@
- 地图未放置有效地面瓦片的纯黑虚空区域,必须在子网格(Sub-tile)上严格打满 `blocked = 1` 与 `COLLIDE_MASK_INVALID`(包含 `COLLIDE_BLANK = 0x0001` 与 `COLLIDE_WALL = 0x0004`)。
- 玩家出生点搜寻(`findIsoSpawn`)**绝对严禁**直接取地图几何中心点 `(width/2, height/2)`。在非矩形或中空地图(如遗忘之塔外景、皇宫、秘密避难所等),中心点极可能是纯黑虚空。
- 出生点必须严格落在“物理连通、有非隐藏有效地面瓦片且 `blocked === 0`”的最大可行走连通分量上,优先靠近传送门(Warp)或小站(Waypoint)。
3. **消除平铺条纹:严禁在空间离散中使用简单线性算术**:
- 地块变体选择(`pickVariant`)严禁使用 `((x * A) ^ (y * B)) % count` 这类简单线性同余公式。在二维离散规则网格上,线性乘积会导致明显的 45 度斜向周期性机械几何平铺条纹。
- 变体散列必须使用高熵伪随机空间哈希(如 SplitMix64 坐标散列),并通过空间方差统计单测确保视觉离散度。
3. **DRLG 精确瓦片绑定与预设 DS1 消除平铺条纹准则**:
- **DRLG 生成关卡(Exact Tile Pinning)**:凡经由 1.13c DRLG 管线(`DRLGROOMTILE_GetTileCache` -> `D2CMP_10088_SelectTileData`)生成的地图,必须在 `Ds1Floor` / `Ds1Wall` 中直接固定(Pin)DRLG 选出的精确 `dt1Library` 与 `dt1Tile`(含 `type 3` 顶角墙伴随的 `type 4` 左半角 `companionDt1Library` / `companionDt1Tile`),渲染器直接按该索引取帧,严禁二次重抽。
- **预设 DS1 关卡(SplitMix64 Variant Fallback)**:仅在不携带 `pTile` 绑定的原始预设 `.ds1` 关卡中,地块变体选择(`pickVariant`)使用高熵伪随机空间哈希(SplitMix64 坐标散列),严禁使用 `((x * A) ^ (y * B)) % count` 这类简单线性同余公式,并通过空间方差统计单测确保视觉离散度。
---

View File

@ -694,10 +694,20 @@ function parseIds(spec: string): Set<number> {
function parseArgs(argv: readonly string[]): Map<string, string> {
const known = new Set(['pack', 'out', 'jobs', 'act', 'levels', 'shots', 'timeout', 'chrome'])
const args = new Map<string, string>()
for (const arg of argv) {
const match = /^--([a-z]+)=(.+)$/.exec(arg)
if (match === null || !known.has(match[1]!)) throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`)
args.set(match[1]!, match[2]!)
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!
const eqMatch = /^--([a-z]+)=(.+)$/.exec(arg)
if (eqMatch !== null && known.has(eqMatch[1]!)) {
args.set(eqMatch[1]!, eqMatch[2]!)
continue
}
const flagMatch = /^--([a-z]+)$/.exec(arg)
if (flagMatch !== null && known.has(flagMatch[1]!) && i + 1 < argv.length) {
args.set(flagMatch[1]!, argv[i + 1]!)
i += 1
continue
}
throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`)
}
return args
}

View File

@ -279,7 +279,7 @@ function buildTokenJoin(
for (const row of monstats.rows) {
const token = cell(monstats, row, 'Code').trim().toUpperCase()
const ex = cell(monstats, row, 'MonStatsEx').trim()
if (token.length === 0 || token === 'XX') continue
if (token.length === 0) continue
if (ex.length === 0 || !rows.has(ex)) continue
const ids = join.get(token) ?? []
if (!ids.includes(ex)) ids.push(ex)
@ -301,7 +301,7 @@ function bakedMonsterTokens(): Set<string> {
const tokens = new Set<string>()
for (let act = 1; act <= 5; act += 1) {
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
if (!spec.token) continue
tokens.add(spec.token.toUpperCase())
}
}
@ -314,7 +314,7 @@ function bakedMonsterTokens(): Set<string> {
}
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') tokens.add(spec.token.toUpperCase())
if (spec && spec.token) tokens.add(spec.token.toUpperCase())
}
return tokens
}

View File

@ -735,7 +735,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
if (!spec.token) continue
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -762,7 +762,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (const monsterId of allSuperUniqueMonsterIds) {
const spec = resolveMonsterArtSpec(monsterId)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
if (spec && spec.token) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -773,7 +773,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
// Include special summon and minion entities (Valkyrie, Decoy/Dopplezon)
for (const summonId of ['valkyrie', 'dopplezon']) {
const spec = resolveMonsterArtSpec(summonId)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
if (spec && spec.token) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -804,7 +804,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
if (!spec.token) continue
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
const list = monsterActsMap.get(key) ?? []
if (!list.includes(act)) list.push(act)

View File

@ -82,7 +82,7 @@ function slug(name: string): string {
interface Args {
readonly copy: number
readonly seed: number
readonly levels: readonly number[]
readonly levels?: readonly number[]
readonly out: string
readonly scale: number
}
@ -100,12 +100,13 @@ function parseArgs(argv: readonly string[]): Args {
const seedText = values.get('seed')
const seed = seedText === undefined ? act1GameSeed(copy) : Number(seedText) >>> 0
if (seedText !== undefined && !/^(0x[0-9a-f]+|\d+)$/i.test(seedText)) throw new Error(`bad --seed ${seedText}`)
const levels = (values.get('levels') ?? '2,3,4').split(',').map(Number)
if (levels.some(id => !Number.isInteger(id) || id <= 0)) throw new Error(`bad --levels ${values.get('levels') ?? ''}`)
const levelsSpec = values.get('levels')
const levels = levelsSpec === undefined ? undefined : levelsSpec.split(',').map(Number)
if (levels !== undefined && levels.some(id => !Number.isInteger(id) || id <= 0)) throw new Error(`bad --levels ${levelsSpec ?? ''}`)
const scale = Number(values.get('scale') ?? '4')
if (!Number.isInteger(scale) || scale < 1) throw new Error('--scale must be a positive integer')
const out = resolve(values.get('out') ?? '/tmp/drlg-compare')
return { copy, seed, levels, out, scale }
return { copy, seed, ...(levels !== undefined ? { levels } : {}), out, scale }
}
// ---------------------------------------------------------------------------------------------
@ -277,6 +278,54 @@ function blueprintXor(a: Blueprint, b: Blueprint): { differing: number; png: Uin
return { differing, png: encodeIndexedPng({ width: a.width, height: a.height, pixels, palette }) }
}
/**
* Count how many visible draws in `scene` differ in `(library, tile)` from the exact `pTile`
* pointers chosen by the native D2MOO oracle in `oracleDump`.
*/
function countVariantDiffs(oracleDump: DrlgDump, levelId: number, tables: DrlgMpqData['tables'], map: DrlgLevelMap, scene: IsoMapScene): number {
const input = drlgLevelInputFromDump(oracleDump, levelId, tables)
const cellsX = input.coord.width + 1
const cellsY = input.coord.height + 1
const seen = new Set<string>()
const expectedByCellKind = new Map<string, string[]>()
for (const t of input.tiles) {
if (seen.has(t.key)) continue
seen.add(t.key)
if (t.x < 0 || t.y < 0 || t.x >= cellsX || t.y >= cellsY) continue
if (t.tile === null) continue
// buildIsoMapScene skips hidden floors and walls (MAPTILE_HIDDEN = 0x8), while shadows are drawn whenever prop1 !== 0
if ((t.kind === 'floor' || t.kind === 'wall') && (t.flags & 0x0000_0008) !== 0) continue
const key = `${String(t.x)},${String(t.y)},${t.kind}`
const list = expectedByCellKind.get(key) ?? []
list.push(`${t.tile.library}#${String(t.tile.index)}`)
expectedByCellKind.set(key, list)
}
const actualByCellKind = new Map<string, string[]>()
const recordDraw = (draw: IsoDraw, kind: 'floor' | 'shadow' | 'wall'): void => {
const key = `${String(draw.cellX)},${String(draw.cellY)},${kind}`
const list = actualByCellKind.get(key) ?? []
list.push(`${map.dt1Names[draw.library] ?? '?'}#${String(draw.tile)}`)
actualByCellKind.set(key, list)
}
for (const d of scene.floors) recordDraw(d, 'floor')
for (const d of scene.shadows) recordDraw(d, 'shadow')
for (const d of scene.walls) recordDraw(d, 'wall')
for (const d of scene.roofs) recordDraw(d, 'wall')
const allKeys = new Set([...expectedByCellKind.keys(), ...actualByCellKind.keys()])
let diffs = 0
for (const key of allKeys) {
const exp = (expectedByCellKind.get(key) ?? []).slice().sort()
const act = (actualByCellKind.get(key) ?? []).slice().sort()
const maxLen = Math.max(exp.length, act.length)
for (let i = 0; i < maxLen; i += 1) {
if (exp[i] !== act[i]) diffs += 1
}
}
return diffs
}
// ---------------------------------------------------------------------------------------------
// Driver
@ -307,12 +356,13 @@ async function main(): Promise<void> {
}
// The bake generates the whole act's outdoor list in one run; so do both sides here.
const actLevels = act1OutdoorLevelIds(data.tables)
for (const levelId of args.levels) {
const targetLevels = args.levels ?? actLevels
for (const levelId of targetLevels) {
if (!actLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an Act I outdoor level (${actLevels.join(', ')})`)
}
const seedHex = `0x${args.seed.toString(16).padStart(8, '0')}`
console.log(`game seed ${seedHex} (act copy ${String(args.copy)}), levels ${args.levels.join(', ')} of [${actLevels.join(', ')}]`)
console.log(`game seed ${seedHex} (act copy ${String(args.copy)}), levels ${targetLevels.join(', ')} of [${actLevels.join(', ')}]`)
const oracle: DrlgDump = runOracle({ seed: args.seed, levels: actLevels, difficulty: 0, isolated: false }).doc
delete oracle.oracle
const port: DrlgDump = dumpAct1(createDrlgEnv(data.source, data.tables), args.seed, actLevels, { difficulty: 0, isolated: false })
@ -328,20 +378,24 @@ async function main(): Promise<void> {
}
return dt1
}
const side = (dump: DrlgDump, levelId: number): Side => {
const side = (dump: DrlgDump, levelId: number, fallbackSeed: number): Side => {
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), bakeSceneSeed(levelId, args.copy))
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), fallbackSeed)
return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) }
}
const summary: Record<string, unknown>[] = []
const ambiguityTotal = { refs: 0, variantGroups: 0, crossLibrary: 0, crossLibraryLevelWide: 0 }
let totalVariantDiffs = 0
let failed = dumpDiff !== null
for (const levelId of args.levels) {
for (const levelId of targetLevels) {
const name = slug(levelName(levelId))
const d2moo = side(oracle, levelId)
const ours = side(port, levelId)
// Pass distinct fallback seeds (0 vs bakeSceneSeed) so any unpinned tile would diverge between d2moo and ours
const d2moo = side(oracle, levelId, 0)
const ours = side(port, levelId, bakeSceneSeed(levelId, args.copy))
const variantDiffs = countVariantDiffs(oracle, levelId, data.tables, ours.map, ours.scene)
totalVariantDiffs += variantDiffs
const iso = isoXor(d2moo.iso, ours.iso, args.scale)
const bp = blueprintXor(d2moo.blueprint, ours.blueprint)
const ambiguity: DrlgDt1Ambiguity = auditDrlgDt1Ambiguity(ours.map, ours.map.dt1Names.map(library))
@ -369,21 +423,22 @@ async function main(): Promise<void> {
entrances: ours.map.entrances.length,
landmarks: ours.map.landmarks.length,
isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`,
variantDiffs,
isoDiffering: iso.differing,
blueprintDiffering: bp.differing,
dt1Ambiguity: ambiguity,
}
summary.push(row)
console.log(`L${String(levelId)} ${name}: canvas ${row.canvas}, ${String(s.walls)} walls / ${String(s.floors)} floors / ${String(s.shadows)} shadows, `
+ `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`)
+ `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; variantDiffs ${String(variantDiffs)}; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`)
console.log(` DT1 ambiguity: ${String(ambiguity.libraries)} libraries, ${String(ambiguity.refs)} refs, ${String(ambiguity.variantGroups)} with variants, `
+ `${String(ambiguity.crossLibrary)} cross-library under cell masks (${String(ambiguity.crossLibraryLevelWide)} with one level-wide pool)`)
if (iso.differing !== 0 || bp.differing !== 0) failed = true
if (variantDiffs !== 0 || iso.differing !== 0 || bp.differing !== 0) failed = true
}
console.log(`DT1 ambiguity total: ${String(ambiguityTotal.refs)} refs, ${String(ambiguityTotal.variantGroups)} with variants, `
+ `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool)`)
writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: seedHex, copy: args.copy, dumpsIdentical: dumpDiff === null, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1))
console.log(failed ? 'FAIL: the D2MOO and port images differ' : `OK: every XOR image is empty; images in ${args.out}`)
+ `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool), variantDiffs ${String(totalVariantDiffs)}`)
writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: seedHex, copy: args.copy, dumpsIdentical: dumpDiff === null, variantDiffs: totalVariantDiffs, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1))
console.log(failed ? 'FAIL: the D2MOO and port images or tile variants differ' : `OK: 0 variant diffs and every XOR image is empty; images in ${args.out}`)
if (failed) process.exitCode = 1
}

View File

@ -58,6 +58,14 @@ export interface Ds1Wall {
readonly unknown2: number
/** The cell is hidden. */
readonly hidden: boolean
/** Exact DT1 library index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Library?: number
/** Exact DT1 tile index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Tile?: number
/** Exact DT1 library index for a top-corner-left (type 4) companion of a type-3 wall. */
readonly companionDt1Library?: number
/** Exact DT1 tile index for a top-corner-left (type 4) companion of a type-3 wall. */
readonly companionDt1Tile?: number
}
/** One floor or shadow cell. */
@ -74,6 +82,10 @@ export interface Ds1Floor {
readonly unknown2: number
/** The cell is hidden. */
readonly hidden: boolean
/** Exact DT1 library index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Library?: number
/** Exact DT1 tile index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Tile?: number
}
/** One substitution cell (raw value; its meaning is map-specific). */

View File

@ -508,6 +508,41 @@ function filterByDt1Mask(
return filtered.length > 0 ? filtered : candidates
}
/**
* Resolve a DRLG-pinned DT1 library and tile index directly, bypassing `pickVariant`.
*/
function resolvePinnedTile(
pool: TilePool,
libraries: readonly Dt1[],
dt1Library: number | undefined,
dt1Tile: number | undefined,
style: number,
sequence: number,
type: number,
): ResolvedCandidates | undefined {
if (dt1Library === undefined || dt1Tile === undefined) return undefined
const rawTile = libraries[dt1Library]?.tiles[dt1Tile]
if (rawTile === undefined) return undefined
const animated = isAnimatedTile(rawTile)
const tile: PoolTile = {
library: dt1Library,
tile: dt1Tile,
weight: rawTile.rarityFrameIndex,
animated,
frameIndex: animated ? Math.max(0, rawTile.rarityFrameIndex) : 0,
}
if (!animated) return { tile }
const candidates = pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`)
const animatedTiles = candidates
?.filter(c => c.animated && c.library === dt1Library)
.slice()
.sort((a, b) => a.frameIndex - b.frameIndex || a.tile - b.tile)
return {
tile,
...(animatedTiles && animatedTiles.length > 1 ? { animatedTiles } : {}),
}
}
/**
* Resolve a DS1 reference against the pool.
*
@ -982,7 +1017,10 @@ export function buildIsoMapScene(
// shadows, trees and roofs — so a weighted-random draw could put a dark wall
// tile in a floor slot, drawn with the floor's offset (no `minBlockY + 80`),
// which is the "misplaced black block on the ground" defect.
const picked = resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
const pinned = resolvePinnedTile(pool, libraries, floor.dt1Library, floor.dt1Tile, floor.style, floor.sequence, FLOOR_TILE_TYPE)
const picked = pinned !== undefined
? { tile: pinned.tile, animatedTiles: pinned.animatedTiles, viaLoose: false }
: resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
if (picked.viaLoose) looseRefs += 1
const found = picked.tile
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
@ -1005,7 +1043,8 @@ export function buildIsoMapScene(
// Shadows must come from a **type 13** tile specifically: the loose
// fallback ignores the type field and would happily return a wall tile,
// stamping its blocking flags as if they were a shadow's.
const picked = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
const picked = resolvePinnedTile(pool, libraries, shadow.dt1Library, shadow.dt1Tile, shadow.style, shadow.sequence, SHADOW_TILE_TYPE)
?? resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
const found = picked.tile
if (found === undefined) continue
const tile = libraries[found.library]!.tiles[found.tile]!
@ -1035,7 +1074,10 @@ export function buildIsoMapScene(
for (const wall of cell.walls) {
if (wall.hidden || wall.prop1 === 0) continue
const pickedWall = resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed, cell.dt1Mask)
const pinnedWall = resolvePinnedTile(pool, libraries, wall.dt1Library, wall.dt1Tile, wall.style, wall.sequence, wall.type)
const pickedWall = pinnedWall !== undefined
? { tile: pinnedWall.tile, animatedTiles: pinnedWall.animatedTiles, viaLoose: false }
: resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed, cell.dt1Mask)
if (pickedWall.viaLoose) looseRefs += 1
const found = pickedWall.tile
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
@ -1067,7 +1109,8 @@ export function buildIsoMapScene(
// engine's DS1s never list the left half themselves, so this is the only way
// the corner gets both faces — 411 of them across the 35 preset levels.
if (tile.type === TOP_CORNER_RIGHT_TYPE) {
const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed, cell.dt1Mask)
const companionPicked = resolvePinnedTile(pool, libraries, wall.companionDt1Library, wall.companionDt1Tile, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE)
?? resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed, cell.dt1Mask)
const companion = companionPicked.tile
if (companion === undefined) {
missingCornerPairs += 1

View File

@ -201,6 +201,8 @@ export interface DrlgCellRef {
readonly type: number
/** Index into {@link DrlgLevelMap.dt1Names}. */
readonly library: number
/** Index into the DT1 library's `tiles` array. */
readonly tile: number
readonly hidden: boolean
}
@ -370,6 +372,7 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
if (header.type === TILETYPE_WALL_TOP_CORNER_LEFT) {
// D2 stores the left half of a top corner as its own tile data next to the type-3 tile
// (DRLGROOMTILE_InitWallTileData); buildIsoMapScene draws that half itself for every type-3 wall.
used.add(t.tile.library)
topCornerLefts.push({ t, style: header.style, sequence: header.sequence })
continue
}
@ -386,6 +389,13 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
const dt1Names = libraryOrder(ctx.tables, input.levelType, used, levelId)
const libIndex = new Map(dt1Names.map((name, i) => [name, i]))
const topCornerLeftByKey = new Map<string, { library: number; tile: number }[]>()
for (const c of topCornerLefts) {
const k = `${c.t.x},${c.t.y},${c.style},${c.sequence}`
const list = topCornerLeftByKey.get(k) ?? []
list.push({ library: libIndex.get(c.t.tile!.library)!, tile: c.t.tile!.index })
topCornerLeftByKey.set(k, list)
}
const refs: DrlgCellRef[] = []
let order = 0
let walls = 0
@ -394,21 +404,49 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
for (const p of placed) {
const cell = cells[p.t.y * cellsX + p.t.x]!
const library = libIndex.get(p.t.tile!.library)!
const tileIndex = p.t.tile!.index
cell.mask |= 1 << library
const layer = (p.t.flags >>> MAPTILE_LAYER_SHIFT) & MAPTILE_LAYER_MASK
// prop1 only marks the slot as used for the renderer; hidden tiles (warp lit tiles, etc.) are kept
// hidden exactly as D2 adds them.
if (p.t.kind === 'floor') {
cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden } })
cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden, dt1Library: library, dt1Tile: tileIndex } })
floors += 1
} else if (p.t.kind === 'shadow') {
cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden })
cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden, dt1Library: library, dt1Tile: tileIndex })
shadows += 1
} else {
cell.walls.push({ layer, order: order++, wall: { prop1: 1, sequence: p.sequence, style: p.style, type: p.type, unknown1: 0, unknown2: 0, hidden: p.hidden } })
let companionDt1Library: number | undefined
let companionDt1Tile: number | undefined
if (p.type === TILETYPE_WALL_TOP_CORNER_RIGHT) {
const k = `${p.t.x},${p.t.y},${p.style},${p.sequence}`
const companion = topCornerLeftByKey.get(k)?.shift()
if (companion !== undefined) {
companionDt1Library = companion.library
companionDt1Tile = companion.tile
cell.mask |= 1 << companion.library
}
}
cell.walls.push({
layer,
order: order++,
wall: {
prop1: 1,
sequence: p.sequence,
style: p.style,
type: p.type,
unknown1: 0,
unknown2: 0,
hidden: p.hidden,
dt1Library: library,
dt1Tile: tileIndex,
...(companionDt1Library !== undefined ? { companionDt1Library } : {}),
...(companionDt1Tile !== undefined ? { companionDt1Tile } : {}),
},
})
walls += 1
}
refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, hidden: p.hidden })
refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, tile: tileIndex, hidden: p.hidden })
}
let wallLayers = 1

View File

@ -765,8 +765,13 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
preferFurthest: true,
})
let wpSide: string | undefined
if (levelId === 123) {
const wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)]!
let wpIdx = rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)
if (ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[wpIdx] === downSide) {
wpIdx = (wpIdx + 1) % ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length
}
wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[wpIdx]!
placeNamedPiece(ctx, {
kind: 'waypoint',
name: `Act 5 - Temple ${wpSide} Waypoint`,
@ -776,13 +781,15 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
})
}
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 5 - Temple SE up',
sides: 'SE',
plainSides: 'SE',
preferFurthest: false,
})
if (wpSide !== 'SE') {
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 5 - Temple SE up',
sides: 'SE',
plainSides: 'SE',
preferFurthest: false,
})
}
}
}

View File

@ -398,6 +398,17 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
"mephistospirit": { token: "M6", weapon: "hth" },
"meshif1": { token: "MS", weapon: "hth" },
"meshif2": { token: "M3", weapon: "hth" },
"minion1": { token: "XX", weapon: "hth" },
"minion2": { token: "XX", weapon: "hth" },
"minion3": { token: "XX", weapon: "hth" },
"minion4": { token: "XX", weapon: "hth" },
"minion5": { token: "XX", weapon: "hth" },
"minion6": { token: "XX", weapon: "hth" },
"minion7": { token: "XX", weapon: "hth" },
"minion8": { token: "XX", weapon: "hth" },
"minion9": { token: "XX", weapon: "hth" },
"minion10": { token: "XX", weapon: "hth" },
"minion11": { token: "XX", weapon: "hth" },
"minionspawner1": { token: "XA", weapon: "hth" },
"minionspawner2": { token: "XA", weapon: "hth" },
"minionspawner3": { token: "XA", weapon: "hth" },
@ -742,7 +753,7 @@ export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }],
3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }],
4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }],
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }],
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }, { token: "XX", weapon: "hth" }],
}
/**
@ -751,13 +762,13 @@ export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
const lower = id.toLowerCase()
const direct = MONSTER_ART_MAP[lower]
if (direct && direct.token !== 'XX' && direct.token.trim() !== '') {
if (direct && direct.token.trim() !== '') {
return direct
}
const base = lower.replace(/\d+$/, '')
if (base !== lower) {
const baseSpec = MONSTER_ART_MAP[base]
if (baseSpec && baseSpec.token !== 'XX' && baseSpec.token.trim() !== '') {
if (baseSpec && baseSpec.token.trim() !== '') {
return baseSpec
}
}

View File

@ -5795,6 +5795,8 @@ interface PackIndexEntry {
readonly ds1?: string
/** DRLG act variant of a generated level (see act-variants.ts). */
readonly actVariant?: number
/** DRLG parity fidelity tier of this level. */
readonly fidelity?: 'exact' | 'layout' | 'approximation'
}
/** Object frame inside an object atlas page. */
@ -5839,6 +5841,8 @@ interface PackSceneJson {
readonly levelId?: number
readonly levelName: string
readonly ds1: string
/** DRLG parity fidelity tier of this scene. */
readonly fidelity?: 'exact' | 'layout' | 'approximation'
readonly cellsX: number
readonly cellsY: number
readonly originX: number

View File

@ -86,15 +86,15 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', ()
expect(meta.height).toBe(1929)
})
it('verifies all 70 monster R8 atlases and metadata schemas', () => {
it('verifies all 71 monster R8 atlases and metadata schemas', () => {
expect(existsSync(indexJsonPath)).toBe(true)
const index = JSON.parse(readFileSync(indexJsonPath, 'utf8'))
expect(index.schema).toBe(2)
expect(index.character).toBe('char-so')
expect(Array.isArray(index.entities)).toBe(true)
// 69 + EC (Hell Bovine, Moo Moo Farm)
expect(index.entities.length).toBe(70)
// 69 + EC (Hell Bovine, Moo Moo Farm) + XX (Minion of Destruction, Act 5)
expect(index.entities.length).toBe(71)
for (const entity of index.entities) {
expect(entity.key).toBeDefined()

View File

@ -38,16 +38,15 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true)
const baselineHash = sha256File(ENTITIES_INDEX_PATH)
// Expected canonical SHA-256 for Schema 2 70-monster entity index (including vk-hth Valkyrie
// and ec-hth Hell Bovine: MonStats.txt hellbovine Code=EC, level 39 Moo Moo Farm). Adding
// ec-hth is the only change against the previous 69-monster index (51da3628…).
expect(baselineHash).toBe('675757cffde277b24e2aec11b7440243654282cbb743424ef02ce1d253b41c66')
// Expected canonical SHA-256 for Schema 2 71-monster entity index (including vk-hth Valkyrie,
// ec-hth Hell Bovine, and xx-hth Minion of Destruction: MonStats.txt minion1..11 Code=XX).
expect(baselineHash).toBe('593ca49d34479d0864687aba4dc798bac4351ccdba6c6b97e2af3167292c6e6d')
const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8')
const parsed = JSON.parse(baselineContent)
expect(parsed.schema).toBe(2)
expect(parsed.character).toBe('char-so')
expect(parsed.entities).toHaveLength(70)
expect(parsed.entities).toHaveLength(71)
// Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present
const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2']

View File

@ -530,6 +530,37 @@ describe('Diablo II DRLG Maze Special Passes', () => {
expect(observedDirs).toContain('SW')
expect(observedDirs).toContain('SE')
})
test('Halls of Pain (level 123): always places waypoint in bounded 60x60 2x2 ring across var1..var3 and 100 seeds (Issue #501)', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const seeds = [
0x5eed0000 + 123 + 0 * 555,
0x5eed0000 + 123 + 1 * 555,
0x5eed0000 + 123 + 2 * 555,
...Array.from({ length: 100 }, (_, i) => i + 1),
]
for (const seed of seeds) {
const res = generateMaze({
levelId: 123,
levelName: 'Act 5 - Temple 2',
sectionSize: 30,
minRooms: 4,
merge: 1000,
seed,
pieces,
maxCellsX: 60,
maxCellsY: 60,
})
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const wp = specials.find(s => s.kind === 'waypoint')
const down = specials.find(s => s.kind === 'down')
expect(wp, `missing waypoint for seed 0x${seed.toString(16)}`).toBeDefined()
expect(down, `missing down stairs for seed 0x${seed.toString(16)}`).toBeDefined()
expect(wp!.sides).not.toBe(down!.sides)
expect(wp!.room).not.toBe(down!.room)
}
})
})
/* ----------------------------------------------------------------------- *

View File

@ -23,17 +23,24 @@ describe('monster-mapping', () => {
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' })
})
it('does not leak XX tokens or crash on cut monsters', () => {
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'minion1', 'spiritmummy']
it('does not leak cut monsters while resolving Act 5 Minion of Destruction (minion1..11) to token XX', () => {
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'spiritmummy']
for (const id of cutMonsters) {
expect(resolveMonsterArtSpec(id)).toBeNull()
}
for (let i = 1; i <= 11; i += 1) {
expect(resolveMonsterArtSpec(`minion${i}`)).toEqual({ token: 'XX', weapon: 'hth' })
}
})
it('ACT_MONSTER_SPECS does not contain any XX tokens in any act', () => {
it('ACT_MONSTER_SPECS includes token XX only in Act 5 (Minion of Destruction)', () => {
for (const [act, specs] of Object.entries(ACT_MONSTER_SPECS)) {
for (const spec of specs) {
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
if (Number(act) === 5) {
expect(specs).toContainEqual({ token: 'XX', weapon: 'hth' })
} else {
for (const spec of specs) {
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
}
}
}
})

View File

@ -104,14 +104,14 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
const charR8 = readFileSync(charR8Path)
expect(charR8.byteLength).toBe(charMeta.width * charMeta.height)
// Check all 66 unique monster specs exist as global .r8 files (EC = Hell Bovine, Moo Moo Farm)
// Check all 67 unique monster specs exist as global .r8 files (including EC = Hell Bovine and XX = Minion of Destruction)
const uniqueSpecs = new Set<string>()
for (let act = 1; act <= 5; act += 1) {
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`)
}
}
expect(uniqueSpecs.size).toBe(66)
expect(uniqueSpecs.size).toBe(67)
for (const key of uniqueSpecs) {
const slug = `monster-${key}`

View File

@ -125,26 +125,25 @@ describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => {
expect(su.id).toBeTruthy()
expect(su.monsterId).toBeTruthy()
// Boss class must resolve to an art spec or return null for dummy 'xx' codes gracefully
// Boss class must resolve to an authentic art spec (including minion1 -> token 'XX')
const bossArt = resolveMonsterArtSpec(su.monsterId)
expect(bossArt).not.toBeNull()
expect(bossArt?.token).toBeTruthy()
if (su.monsterId === 'minion1') {
// minion1 has Code="xx" in MonStats.txt and resolveMonsterArtSpec returns null
expect(bossArt).toBeNull()
expect(bossArt).toEqual({ token: 'XX', weapon: 'hth' })
} else {
expect(bossArt).not.toBeNull()
expect(bossArt?.token).toBeTruthy()
expect(bossArt?.token.toLowerCase()).not.toBe('xx')
}
}
})
test('Dummy code xx monsters resolve to null and do not throw in resolveMonsterArtSpec', () => {
// minion1..minion11 have Code="xx" in MonStats
test('Act 5 Minion of Destruction (minion1..11) resolve to token XX while unknown/empty IDs resolve to null', () => {
// minion1..minion11 have authentic D2exp.mpq art under data\global\monsters\XX\
for (let i = 1; i <= 11; i += 1) {
const art = resolveMonsterArtSpec(`minion${i}`)
expect(art).toBeNull()
expect(art).toEqual({ token: 'XX', weapon: 'hth' })
}
// Unknown or dummy
// Unknown or empty IDs
expect(resolveMonsterArtSpec('xx')).toBeNull()
expect(resolveMonsterArtSpec('')).toBeNull()
})