feat(drlg): generate Act I outdoor levels with the 1:1 D2MOO port (#455)

The eight Act I outdoor levels (Blood Moor 2, Cold Plains 3, Stony Field
4, Dark Wood 5, Black Marsh 6, Tamoe Highland 7, Burial Grounds 17, Moo
Moo Farm 39) are now generated by the function-by-function port of
D2MOO's D2Common DRLG in src/game/drlg, bit-exact against a native D2MOO
oracle. The legacy Act I generator (src/game/wilderness/*, the Blood Moor
branch and the Act I paths of wilderness.ts) is deleted; there is no
fallback. Acts 2-5 are unchanged.

Port (completes the kernel committed earlier):
- Tile-map stage: DRLGROOMTILE_AddRoomMapTiles with its tile-data,
  linked-tile and warp helpers, DRLGPRESET_AddPresetRoomMapTiles,
  DRLGOUTROOM_InitializeDrlgOutdoorRoom (drlg-outroom.ts), the
  DrlgDrlgLogic.cpp parts they reach (drlg-logic.ts), D2CMP shim updates.
- drlg-dump.ts: runAct1() driver API; schema 3 dumps with per-room tile
  lists. The port is now type-checked (tsconfig no longer excludes it).

Integration:
- drlg-map.ts (replaces drlg/adapter.ts): turns a generated level into
  the bake's DS1-shaped canvas with exact (style, sequence, type) per
  tile and per-cell DT1 masks, objects, seams from the DRLG level links,
  warps from their UNIT_TILE preset units; blank cells stay blocked.
- pack-act-assets: one D2 game seed per (act, variant), a DRLG bake job
  and index.json actLayouts; scripts/lib/drlg-mpq-source.ts feeds the
  port from the MPQs with the oracle's file list.
- act-variants.ts: WorldVariants picks one generated copy per act (and
  the Rogue Encampment DS1 that copy fixed); act-scene.ts and
  verify-world-walk both use it. act-scene no longer paints grass into
  the blank cells of DRLG scenes.
- world-graph: remove the invented Stony Field <-> Dark Wood seam
  (gAct1WildernessDrlgLink / gAct1MonasteryDrlgLink keep them apart;
  they meet only through the Underground Passage); world-placement
  anchors the second group at the Monastery Gate (3000, 1000).
- level-links: findBorderOpening takes an optional edge range.
- d2map: dt1MaskAllows extracted, behaviour unchanged.

Tools and tests:
- tools/d2moo-oracle: tile-map stage and schema 3; the 4 fixtures are
  regenerated (scripts/drlg-fixtures.ts).
- scripts/render-drlg-compare.ts (both dumps through the same adapter
  and renderer, fails unless the XOR is empty) and
  scripts/audit-levels-browser.ts (headless audit of every baked map).
- tests/drlg-act1-oracle.test.ts (all stages, all 4 fixtures) and
  tests/drlg-act1-invariants.test.ts; tests and stress scripts of the
  deleted generator are retired or now run on Acts 2-5 only.
- verify-world-walk: start in the town the runtime opens and floor the
  spawn sub-tile like the movement collision does.

Verification:
- drlg-diff --levels 2-7,17,39 --seeds 32: 32/32 identical at every
  stage; --trace-check 64/64, --isolated 32/32, difficulty 1 and 2 16/16.
- render-drlg-compare, 3 bake copies x 8 levels: 24/24 XOR empty.
- Full re-bake: 365 maps, 0 missing tiles. Browser audit: 355/365 maps,
  133/136 level ids clean (112/124/129: Act 5 minions without art, as
  before).
- verify:world-graph 37583 assertions; verify:world-walk 29/29;
  verify:entity-packs, typecheck clean. vitest 6369 passed; the 6
  failures (5 ground-item name formatting, 123-var3 waypoint) and the
  verify:packs / verify:generators failures also occur at bad6806.

Test pins updated, measured on the new bake: SceneEntrance 172 -> 166
and world-graph edges 286 -> 284 (the removed seam), seamless pairs
27 -> 26 and placement pairs 19 -> 18; full-spectrum floors / walls /
PNGs 1008898 / 237542 / 1390 (the old pins matched no bake of the
current code).

TAG=agy
CONV=a4f9163c-a011-4161-b169-7f361b1ed57e
This commit is contained in:
troytt 2026-09-27 15:20:41 +00:00
parent 230c20da67
commit b6fc2512d4
73 changed files with 7109 additions and 15488 deletions

View File

@ -0,0 +1,805 @@
/**
* Headless browser audit of every baked level: all 136 Levels.txt ids, every copy the pack holds.
*
* Each index entry is opened as `acts.html?act=<act>&level=<label>&pack=<pack>` in a fresh tab of a
* headless Chrome (SwiftShader WebGL2), served by a Vite dev server over the working tree. The
* verdicts come from the page's published state (`window.__d2webAct`), the DevTools event stream,
* the screenshot and the pack's own scene.json, never from looking at pictures. Every entry must
* pass all of these:
*
* 1. Boot: the scene reaches `ready` with no `error` and shows the entry that was asked for (act and
* label, loaded from the pack); the simulation ticks and the renderer has drawn frames.
* 2. Clean page: no uncaught exception, no console error or failed assertion, no failed request, no
* HTTP error status. The only exception is the short, fixed KNOWN_ABSENT list (Chrome's favicon
* probe and four missiles the runtime aliases on purpose), which is reported instead.
* 3. Rendering: the map has floor tiles, every atlas page arrives, the screenshot is not blank, and
* every monster the level plans has its art (a monster without art is drawn as a red box).
* 4. Pack integrity: no missing tile, no unresolved tile reference, no unplaced link edge.
* 5. Spawn: the player stands on a walkable sub-tile of a cell that has a floor tile, inside the
* largest connected walkable region that holds floor (largestWalkableRegionWithFloors, the region
* finder the bake and the runtime use), so never in the void and never in a sealed pocket.
* 6. Stability: the level does not switch by itself while the page settles (no spawn on a warp).
*
* Screenshots of the chosen levels (default: the Act I outdoor levels the DRLG port generates) are
* kept in --out, next to report.json.
*
* Usage:
* npx tsx scripts/audit-levels-browser.ts [--pack=samples/d2-packs] [--out=/tmp/level-audit]
* [--jobs=6] [--act=N] [--levels=2-7,17,39] [--shots=2-7,17,39|all|none] [--timeout=180]
* [--chrome=/path/to/chrome]
*
* --pack must lie inside the repository: the dev server serves only the working tree.
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { once } from 'node:events'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve, sep } from 'node:path'
import { inflateSync } from 'node:zlib'
import { createServer } from 'vite'
import { subTileAt, type CollisionGrid } from '../src/game/d2map.ts'
import { largestWalkableRegionWithFloors } from '../src/game/level-links.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
const ROOT = resolve(import.meta.dirname, '..')
/**
* Playwright's Chromium comes first: a plain build without the managed policies and extensions the
* workstation's google-chrome brings along.
*/
const CHROME_CANDIDATES = [
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
'/usr/bin/google-chrome',
]
/** Longest wait for one DevTools command; a hung command fails its entry instead of the audit. */
const COMMAND_TIMEOUT_MS = 60_000
/** Levels.txt ids the audit screenshots by default: the Act I outdoor levels of the DRLG port. */
const DEFAULT_SHOTS = '2-7,17,39'
/** Levels.txt ids 1..136: every level of the game. */
const LEVEL_COUNT = 136
const WINDOW = { width: 1280, height: 840 } as const
/** How long a ready page is watched for late errors or a level switch. */
const SETTLE_MS = 1500
const POLL_MS = 250
/**
* A screenshot counts as blank when fewer than this share of the sampled pixels is lit, or when it
* has fewer distinct colours than this. A drawn map clears both by a wide margin; a black canvas, a
* clear colour or a single placeholder fails both.
*/
const MIN_LIT_SHARE = 0.1
const MIN_COLOURS = 64
/** The bottom part of the window holds the HUD, which draws even when the map does not. */
const MAP_SHARE_OF_HEIGHT = 0.75
const sleep = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
interface IndexEntry {
readonly act: number
readonly levelId: number
readonly label: string
readonly path: string
readonly missingTiles?: number
readonly unplacedEdges?: number
}
interface SceneJson {
readonly cellsX: number
readonly cellsY: number
readonly originX: number
readonly originY: number
readonly floors: readonly (readonly number[])[]
readonly collision: {
readonly width: number
readonly height: number
readonly runs: readonly (readonly number[])[]
}
readonly stats: {
readonly missingTiles: number
readonly missingRefs?: readonly unknown[]
readonly unplacedEdges?: readonly unknown[]
}
}
/** The part of `window.__d2webAct` the audit reads. */
interface PageState {
readonly ready: boolean
readonly error: string | null
readonly source: string
readonly act: number
readonly variant: string
readonly cellsX: number
readonly cellsY: number
readonly floors: number
readonly walls: number
readonly frames: number
readonly pagesLoaded: number
readonly pagesTotal: number
readonly pagesAtFirstFrame: number
readonly drawCalls: number
readonly quadsDrawn: number
readonly tick: number
readonly x: number
readonly y: number
readonly missingMonsterArt: readonly string[]
readonly monsterArtErrors: number
readonly monsterArtLayerFailures: number
readonly monstersPlanned: number
readonly loadMs: number
}
const STATE_EXPRESSION = `(() => {
const s = window.__d2webAct
if (!s) return null
return {
ready: s.ready, error: s.error ?? null, source: s.source, act: s.act, variant: s.variant,
cellsX: s.cellsX, cellsY: s.cellsY, floors: s.floors, walls: s.walls, frames: s.frames,
pagesLoaded: s.pagesLoaded, pagesTotal: s.pagesTotal, pagesAtFirstFrame: s.pagesAtFirstFrame,
drawCalls: s.drawCalls, quadsDrawn: s.quadsDrawn, tick: s.tick, x: s.x, y: s.y,
missingMonsterArt: [...(s.missingMonsterArt ?? [])], monsterArtErrors: s.monsterArtErrors,
monsterArtLayerFailures: s.monsterArtLayerFailures, monstersPlanned: s.monstersPlanned, loadMs: s.loadMs,
}
})()`
interface SpawnCheck {
readonly subX: number
readonly subY: number
readonly inBounds: boolean
readonly walkable: boolean
readonly floor: boolean
readonly inMainRegion: boolean
/** Sub-tiles of the walkable component the player stands in (0 when standing on a blocked one). */
readonly component: number
/** Sub-tiles of the largest walkable region that holds floor. */
readonly mainRegion: number
}
interface ScreenStats {
readonly litShare: number
readonly colours: number
}
interface Verdict {
readonly label: string
readonly act: number
readonly levelId: number
readonly ok: boolean
readonly problems: readonly string[]
readonly state: PageState | null
readonly spawn: SpawnCheck | null
readonly screen: ScreenStats | null
readonly screenshot: string | null
readonly ms: number
/** Known-absent URLs the page asked for ({@link KNOWN_ABSENT}); reported, not failed. */
readonly knownAbsent: readonly string[]
}
interface AuditContext {
readonly baseUrl: string
readonly packDir: string
readonly packPath: string
readonly outDir: string
readonly timeoutMs: number
readonly shots: 'all' | ReadonlySet<number>
}
// ---------------------------------------------------------------------------------------------
// DevTools protocol
interface CdpEvent {
readonly method: string
readonly params: Record<string, unknown>
}
interface CdpReply {
readonly id?: number
readonly method?: string
readonly params?: Record<string, unknown>
readonly result?: unknown
readonly error?: { readonly message: string }
}
/** One DevTools WebSocket: numbered commands and an event stream. */
class CdpSession {
private nextId = 1
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
private listener: ((event: CdpEvent) => void) | null = null
private constructor(private readonly socket: WebSocket) {
socket.onmessage = message => {
const reply = JSON.parse(String(message.data)) as CdpReply
if (reply.id !== undefined) {
const waiter = this.pending.get(reply.id)
if (waiter === undefined) return
this.pending.delete(reply.id)
if (reply.error !== undefined) waiter.reject(new Error(reply.error.message))
else waiter.resolve(reply.result)
} else if (reply.method !== undefined) {
this.listener?.({ method: reply.method, params: reply.params ?? {} })
}
}
socket.onclose = () => {
for (const waiter of this.pending.values()) waiter.reject(new Error('the DevTools socket closed'))
this.pending.clear()
}
}
static async connect(url: string): Promise<CdpSession> {
const socket = new WebSocket(url)
await new Promise<void>((done, fail) => {
socket.onopen = () => done()
socket.onerror = () => fail(new Error(`cannot open ${url}`))
})
return new CdpSession(socket)
}
onEvent(listener: (event: CdpEvent) => void): void {
this.listener = listener
}
send<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const id = this.nextId++
return new Promise<T>((done, fail) => {
const timer = setTimeout(() => {
this.pending.delete(id)
fail(new Error(`${method} got no answer within ${String(COMMAND_TIMEOUT_MS / 1000)} s`))
}, COMMAND_TIMEOUT_MS)
this.pending.set(id, {
resolve: value => { clearTimeout(timer); done(value as T) },
reject: error => { clearTimeout(timer); fail(error) },
})
this.socket.send(JSON.stringify({ id, method, params }))
})
}
close(): void {
this.socket.close()
}
}
/** A headless Chrome with its own profile; every audited entry gets a fresh tab. */
class Browser {
private constructor(
private readonly child: ChildProcess,
private readonly profile: string,
private readonly port: number,
) {}
static async launch(bin: string): Promise<Browser> {
const profile = mkdtempSync(join(tmpdir(), 'level-audit-'))
const child = spawn(bin, [
'--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check',
`--window-size=${String(WINDOW.width)},${String(WINDOW.height)}`, '--force-device-scale-factor=1',
// Software WebGL2: there is no GPU here, and SwiftShader runs the real renderer path.
'--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
// A tab must keep animating while the next one opens.
'--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows',
// gLinux routes even 127.0.0.1 through its PAC proxy unless told otherwise (scripts/browser/README.md).
'--no-proxy-server',
// Without this every HTTP request hangs before it is sent: the cookie store waits for the OS
// keyring (Secret Service over D-Bus), which never answers a headless session. The netlog shows
// the main-frame request stop after COMPUTED_PRIVACY_MODE; data: URLs, which need no cookies,
// still load. The basic store keeps the cookie key in the profile.
'--password-store=basic',
// Nothing but the page under audit: no extension may sit between the tab and the dev server.
'--disable-extensions', '--disable-component-extensions-with-background-pages',
`--user-data-dir=${profile}`, '--remote-debugging-port=0', 'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
const portFile = join(profile, 'DevToolsActivePort')
for (let attempt = 0; attempt < 200; attempt += 1) {
await sleep(100)
if (child.exitCode !== null) throw new Error(`Chrome exited with ${String(child.exitCode)}: ${stderr.slice(-400)}`)
if (!existsSync(portFile)) continue
const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
if (Number.isInteger(port) && port > 0) return new Browser(child, profile, port)
}
child.kill('SIGKILL')
throw new Error(`Chrome opened no DevTools port within 20 s: ${stderr.slice(-400)}`)
}
async openTab(): Promise<{ readonly id: string; readonly session: CdpSession }> {
const response = await fetch(`http://127.0.0.1:${String(this.port)}/json/new?about:blank`, {
method: 'PUT', signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`DevTools /json/new answered HTTP ${String(response.status)}`)
const target = await response.json() as { id: string; webSocketDebuggerUrl: string }
return { id: target.id, session: await CdpSession.connect(target.webSocketDebuggerUrl) }
}
async closeTab(id: string): Promise<void> {
const response = await fetch(`http://127.0.0.1:${String(this.port)}/json/close/${id}`, { signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS) })
if (!response.ok) throw new Error(`DevTools /json/close answered HTTP ${String(response.status)}`)
}
async close(): Promise<void> {
if (this.child.exitCode === null) {
const exited = once(this.child, 'exit')
this.child.kill('SIGKILL')
await exited
}
rmSync(this.profile, { recursive: true, force: true })
}
}
// ---------------------------------------------------------------------------------------------
// Checks
function text(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
/**
* Requests that fail on every page by design and belong to no level: Chrome's own favicon probe,
* and the missile art loadMissileArtMap (src/scene/act-scene.ts) looks for under each of its three
* candidate bases before it aliases exactly these four missiles to the firebolt / lightningstrike
* art (no pack bakes them). They are counted in the report instead of failing the entry; any other
* failed request still fails it.
*/
const KNOWN_ABSENT = /\/favicon\.ico$|\/missiles\/(?:hydra|hydrafire|lightning|lightningbolt)\.(?:json|png)$/
/** Record whatever the page reports as broken: exceptions, console errors, failed or erroring requests. */
function collectPageError(event: CdpEvent, requests: Map<string, string>, errors: Set<string>, knownAbsent: Set<string>): void {
const params = event.params
switch (event.method) {
case 'Network.requestWillBeSent': {
const request = params.request as { url?: string } | undefined
requests.set(text(params.requestId), request?.url ?? '')
break
}
case 'Network.responseReceived': {
const response = params.response as { status?: number; url?: string } | undefined
if (response?.status !== undefined && response.status >= 400) {
const url = response.url ?? ''
if (response.status === 404 && KNOWN_ABSENT.test(url)) knownAbsent.add(url)
else errors.add(`HTTP ${String(response.status)} ${url}`)
}
break
}
case 'Network.loadingFailed':
if (params.canceled !== true) {
errors.add(`request failed (${text(params.errorText)}): ${requests.get(text(params.requestId)) ?? text(params.requestId)}`)
}
break
case 'Runtime.exceptionThrown': {
const details = params.exceptionDetails as { text?: string; exception?: { description?: string } } | undefined
const description = details?.exception?.description ?? details?.text ?? 'exception'
errors.add(`uncaught ${description.split('\n').slice(0, 2).join(' | ').slice(0, 400)}`)
break
}
case 'Runtime.consoleAPICalled':
if (params.type === 'error' || params.type === 'assert') {
const args = (params.args as { value?: unknown; description?: string; type?: string }[] | undefined) ?? []
const message = args.map(arg => arg.value !== undefined ? text(arg.value) : arg.description ?? arg.type ?? '').join(' ')
errors.add(`console.${text(params.type)}: ${message.slice(0, 400)}`)
}
break
case 'Log.entryAdded': {
const entry = params.entry as { level?: string; source?: string; text?: string; url?: string } | undefined
if (entry?.level !== 'error') break
// Chrome's console line for a 404 already counted above.
if (entry.source === 'network' && entry.url !== undefined && KNOWN_ABSENT.test(entry.url) && entry.text?.includes('status of 404') === true) {
knownAbsent.add(entry.url)
break
}
errors.add(`${entry.source ?? 'log'}: ${entry.text ?? ''}${entry.url ? ` (${entry.url})` : ''}`)
break
}
default:
break
}
}
async function readState(session: CdpSession): Promise<PageState | null> {
const reply = await session.send<{ result: { value?: PageState | null }; exceptionDetails?: unknown }>(
'Runtime.evaluate', { expression: STATE_EXPRESSION, returnByValue: true },
)
if (reply.exceptionDetails !== undefined) throw new Error(`reading the scene state threw: ${text(reply.exceptionDetails).slice(0, 300)}`)
return reply.result.value ?? null
}
async function waitForState(
session: CdpSession,
done: (state: PageState) => boolean,
deadline: number,
): Promise<PageState | null> {
let state = await readState(session)
while ((state === null || !done(state)) && Date.now() < deadline) {
await sleep(POLL_MS)
state = await readState(session)
}
return state
}
function decodeCollision(collision: SceneJson['collision']): Uint8Array {
const blocked = new Uint8Array(collision.width * collision.height)
let at = 0
for (const run of collision.runs) {
const value = run[0]!
const length = run[1]!
if (value !== 0) blocked.fill(value, at, Math.min(at + length, blocked.length))
at += length
}
if (at !== blocked.length) throw new Error(`collision runs cover ${String(at)} sub-tiles, the grid has ${String(blocked.length)}`)
return blocked
}
/** Where the player stands, judged on the scene's own collision map and floor list. */
function checkSpawn(scene: SceneJson, x: number, y: number): SpawnCheck {
const width = scene.collision.width
const height = scene.collision.height
if (width !== scene.cellsX * SUB_TILES_PER_TILE || height !== scene.cellsY * SUB_TILES_PER_TILE) {
throw new Error(`collision grid ${String(width)}x${String(height)} does not match ${String(scene.cellsX)}x${String(scene.cellsY)} cells`)
}
const blocked = decodeCollision(scene.collision)
const floorCells = new Uint8Array(scene.cellsX * scene.cellsY)
for (const row of scene.floors) {
const cellX = row[3]!
const cellY = row[4]!
if (cellX >= 0 && cellX < scene.cellsX && cellY >= 0 && cellY < scene.cellsY) floorCells[cellY * scene.cellsX + cellX] = 1
}
const hasFloor = (cellX: number, cellY: number): boolean =>
cellX >= 0 && cellX < scene.cellsX && cellY >= 0 && cellY < scene.cellsY && floorCells[cellY * scene.cellsX + cellX] === 1
const grid: CollisionGrid = {
originX: scene.originX, originY: scene.originY, cellsX: scene.cellsX, cellsY: scene.cellsY, blocked, gridWidth: width,
}
const { subX, subY, inBounds } = subTileAt(grid, x, y)
const region = largestWalkableRegionWithFloors({ cellsX: scene.cellsX, cellsY: scene.cellsY, gridWidth: width, gridHeight: height, blocked }, hasFloor)
let mainRegion = 0
for (const inside of region) mainRegion += inside
const at = subY * width + subX
const walkable = inBounds && blocked[at] === 0
// The component the player stands in, four-connected like the region finder.
let component = 0
if (walkable) {
const seen = new Uint8Array(blocked.length)
const stack = [at]
seen[at] = 1
while (stack.length > 0) {
const here = stack.pop()!
component += 1
const hx = here % width
const neighbours = [
hx > 0 ? here - 1 : -1,
hx < width - 1 ? here + 1 : -1,
here >= width ? here - width : -1,
here + width < blocked.length ? here + width : -1,
]
for (const next of neighbours) {
if (next >= 0 && seen[next] === 0 && blocked[next] === 0) {
seen[next] = 1
stack.push(next)
}
}
}
}
return {
subX,
subY,
inBounds,
walkable,
floor: inBounds && hasFloor(Math.floor(subX / SUB_TILES_PER_TILE), Math.floor(subY / SUB_TILES_PER_TILE)),
inMainRegion: walkable && region[at] === 1,
component,
mainRegion,
}
}
/** Decode an 8-bit, non-interlaced RGB or RGBA PNG (what Page.captureScreenshot returns). */
function decodePng(png: Buffer): { width: number; height: number; channels: number; pixels: Uint8Array } {
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (signature.some((byte, at) => png[at] !== byte)) throw new Error('the screenshot is not a PNG')
let width = 0
let height = 0
let bitDepth = 0
let colourType = -1
let interlace = 0
const chunks: Buffer[] = []
for (let offset = 8; offset < png.length;) {
const length = png.readUInt32BE(offset)
const type = png.toString('latin1', offset + 4, offset + 8)
const data = png.subarray(offset + 8, offset + 8 + length)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]!
colourType = data[9]!
interlace = data[12]!
} else if (type === 'IDAT') {
chunks.push(data)
} else if (type === 'IEND') {
break
}
offset += 12 + length
}
if (bitDepth !== 8 || interlace !== 0 || (colourType !== 2 && colourType !== 6)) {
throw new Error(`unsupported screenshot PNG: depth ${String(bitDepth)}, colour type ${String(colourType)}, interlace ${String(interlace)}`)
}
const channels = colourType === 6 ? 4 : 3
const raw = inflateSync(Buffer.concat(chunks))
const stride = width * channels
if (raw.length !== height * (stride + 1)) throw new Error('truncated screenshot PNG')
const pixels = new Uint8Array(height * stride)
for (let row = 0; row < height; row += 1) {
const filter = raw[row * (stride + 1)]!
const src = row * (stride + 1) + 1
const dst = row * stride
for (let i = 0; i < stride; i += 1) {
const left = i >= channels ? pixels[dst + i - channels]! : 0
const up = row > 0 ? pixels[dst - stride + i]! : 0
const upLeft = row > 0 && i >= channels ? pixels[dst - stride + i - channels]! : 0
const value = raw[src + i]!
let out: number
switch (filter) {
case 0: out = value; break
case 1: out = value + left; break
case 2: out = value + up; break
case 3: out = value + ((left + up) >> 1); break
case 4: {
const estimate = left + up - upLeft
const dLeft = Math.abs(estimate - left)
const dUp = Math.abs(estimate - up)
const dUpLeft = Math.abs(estimate - upLeft)
out = value + (dLeft <= dUp && dLeft <= dUpLeft ? left : dUp <= dUpLeft ? up : upLeft)
break
}
default: throw new Error(`bad PNG filter ${String(filter)}`)
}
pixels[dst + i] = out & 0xff
}
}
return { width, height, channels, pixels }
}
/** How much of the map area of a screenshot is lit, and how many colours it shows. */
function screenStats(png: Buffer): ScreenStats {
const image = decodePng(png)
const rows = Math.floor(image.height * MAP_SHARE_OF_HEIGHT)
const colours = new Set<number>()
let lit = 0
let sampled = 0
for (let row = 0; row < rows; row += 2) {
for (let column = 0; column < image.width; column += 2) {
const at = (row * image.width + column) * image.channels
const r = image.pixels[at]!
const g = image.pixels[at + 1]!
const b = image.pixels[at + 2]!
sampled += 1
if (Math.max(r, g, b) > 24) lit += 1
colours.add(((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3))
}
}
return { litShare: lit / sampled, colours: colours.size }
}
async function auditEntry(browser: Browser, entry: IndexEntry, context: AuditContext): Promise<Verdict> {
const started = Date.now()
const deadline = started + context.timeoutMs
const problems: string[] = []
const pageErrors = new Set<string>()
const knownAbsent = new Set<string>()
const requests = new Map<string, string>()
let state: PageState | null = null
let spawnCheck: SpawnCheck | null = null
let screen: ScreenStats | null = null
let screenshot: string | null = null
const scene = JSON.parse(readFileSync(join(context.packDir, entry.path, 'scene.json'), 'utf8')) as SceneJson
if ((entry.missingTiles ?? 0) !== 0 || scene.stats.missingTiles !== 0) problems.push(`missing tiles: index ${String(entry.missingTiles)}, scene ${String(scene.stats.missingTiles)}`)
if ((scene.stats.missingRefs?.length ?? 0) !== 0) problems.push(`unresolved tile references: ${text(scene.stats.missingRefs).slice(0, 300)}`)
if ((entry.unplacedEdges ?? 0) !== 0 || (scene.stats.unplacedEdges?.length ?? 0) !== 0) problems.push(`unplaced link edges: ${text(scene.stats.unplacedEdges).slice(0, 300)}`)
const { id, session } = await browser.openTab()
try {
session.onEvent(event => collectPageError(event, requests, pageErrors, knownAbsent))
await session.send('Runtime.enable')
await session.send('Log.enable')
await session.send('Network.enable')
await session.send('Page.enable')
const query = new URLSearchParams({ act: String(entry.act), level: entry.label, pack: context.packPath, lighting: 'noon' })
await session.send('Page.navigate', { url: `${context.baseUrl}/acts.html?${query.toString()}` })
const atReady = await waitForState(session, s => s.ready || s.error !== null, deadline)
if (atReady === null) {
problems.push('the page never published window.__d2webAct')
} else if (atReady.error !== null) {
problems.push(`scene error: ${atReady.error}`)
} else if (!atReady.ready) {
problems.push(`not ready after ${String(context.timeoutMs / 1000)} s`)
} else {
if (atReady.source !== 'pack') problems.push(`loaded from "${atReady.source}", not from the pack`)
if (atReady.act !== entry.act || atReady.variant !== entry.label) problems.push(`showed act ${String(atReady.act)} "${atReady.variant}"`)
const drawn = await waitForState(
session,
s => s.error !== null || (s.pagesLoaded >= s.pagesTotal && s.pagesAtFirstFrame >= 0 && s.tick > 0 && s.drawCalls > 0),
deadline,
)
// The spawn is read from the first ticked state: `ready` is published before the frame loop
// first writes the player position (state.x/y are written together with state.tick).
if (drawn === null || drawn.tick <= 0) {
problems.push('no simulation tick, so no spawn position to check')
} else {
spawnCheck = checkSpawn(scene, drawn.x, drawn.y)
if (!spawnCheck.inBounds) problems.push(`spawn outside the map at (${String(drawn.x)}, ${String(drawn.y)})`)
else if (!spawnCheck.walkable) problems.push(`spawn on a blocked sub-tile (${String(spawnCheck.subX)}, ${String(spawnCheck.subY)})`)
else if (!spawnCheck.inMainRegion) problems.push(`spawn in a walkable pocket of ${String(spawnCheck.component)} sub-tiles, outside the main region of ${String(spawnCheck.mainRegion)}`)
if (spawnCheck.inBounds && !spawnCheck.floor) problems.push(`spawn on a cell without a floor tile (sub-tile ${String(spawnCheck.subX)}, ${String(spawnCheck.subY)})`)
}
await sleep(SETTLE_MS)
state = await readState(session)
if (state === null) {
problems.push('the scene state disappeared')
} else {
if (state.error !== null) problems.push(`scene error after ready: ${state.error}`)
if (state.act !== entry.act || state.variant !== entry.label) problems.push(`switched by itself to act ${String(state.act)} "${state.variant}"`)
if (drawn === null || state.pagesLoaded < state.pagesTotal) problems.push(`atlas pages loaded ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`)
if (state.pagesAtFirstFrame < 0) problems.push('no frame drawn')
if (state.tick <= 0) problems.push('the simulation does not tick')
if (state.drawCalls <= 0 || state.quadsDrawn <= 0) problems.push(`nothing drawn (draw calls ${String(state.drawCalls)}, quads ${String(state.quadsDrawn)})`)
if (state.floors <= 0 || state.frames <= 0) problems.push(`empty map (floors ${String(state.floors)}, frames ${String(state.frames)})`)
if (state.cellsX !== scene.cellsX || state.cellsY !== scene.cellsY) problems.push(`page shows ${String(state.cellsX)}x${String(state.cellsY)} cells, the scene has ${String(scene.cellsX)}x${String(scene.cellsY)}`)
if (state.missingMonsterArt.length > 0) problems.push(`monsters without art: ${state.missingMonsterArt.join(', ')}`)
if (state.monsterArtErrors !== 0 || state.monsterArtLayerFailures !== 0) problems.push(`monster art failures: ${String(state.monsterArtErrors)} loads, ${String(state.monsterArtLayerFailures)} layers`)
}
const shot = await session.send<{ data: string }>('Page.captureScreenshot', { format: 'png' })
const png = Buffer.from(shot.data, 'base64')
screen = screenStats(png)
if (screen.litShare < MIN_LIT_SHARE || screen.colours < MIN_COLOURS) {
problems.push(`blank screen (lit ${(screen.litShare * 100).toFixed(1)}%, ${String(screen.colours)} colours)`)
}
if (context.shots === 'all' || context.shots.has(entry.levelId) || problems.length > 0 || pageErrors.size > 0) {
screenshot = join(context.outDir, `${entry.label}.png`)
writeFileSync(screenshot, png)
}
}
if (state === null) state = atReady
} finally {
session.close()
await browser.closeTab(id)
}
for (const error of pageErrors) problems.push(error)
return {
label: entry.label,
act: entry.act,
levelId: entry.levelId,
ok: problems.length === 0,
problems,
state,
spawn: spawnCheck,
screen,
screenshot,
ms: Date.now() - started,
knownAbsent: [...knownAbsent].sort(),
}
}
// ---------------------------------------------------------------------------------------------
// Driver
function parseIds(spec: string): Set<number> {
const ids = new Set<number>()
for (const part of spec.split(',')) {
const range = /^(\d+)(?:-(\d+))?$/.exec(part.trim())
if (range === null) throw new Error(`bad level list "${spec}"`)
const from = Number(range[1])
const to = range[2] === undefined ? from : Number(range[2])
if (to < from) throw new Error(`bad level range "${part}"`)
for (let levelId = from; levelId <= to; levelId += 1) ids.add(levelId)
}
return ids
}
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]!)
}
return args
}
function positiveInteger(value: string, name: string): number {
const number = Number(value)
if (!Number.isInteger(number) || number < 1) throw new Error(`--${name} must be a positive integer, got ${value}`)
return number
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2))
const packDir = resolve(ROOT, args.get('pack') ?? 'samples/d2-packs')
const packPath = relative(ROOT, packDir).split(sep).join('/')
if (packPath === '' || packPath.startsWith('..')) throw new Error(`--pack must be a directory inside ${ROOT}: the dev server serves only the repository`)
const index = JSON.parse(readFileSync(join(packDir, 'index.json'), 'utf8')) as { levels: IndexEntry[] }
const jobs = positiveInteger(args.get('jobs') ?? '6', 'jobs')
const timeoutMs = positiveInteger(args.get('timeout') ?? '180', 'timeout') * 1000
const outDir = resolve(args.get('out') ?? '/tmp/level-audit')
mkdirSync(outDir, { recursive: true })
const act = args.has('act') ? positiveInteger(args.get('act')!, 'act') : undefined
const levels = args.has('levels') ? parseIds(args.get('levels')!) : undefined
const shotsSpec = args.get('shots') ?? DEFAULT_SHOTS
const shots = shotsSpec === 'all' ? 'all' : shotsSpec === 'none' ? new Set<number>() : parseIds(shotsSpec)
const chrome = args.get('chrome') ?? CHROME_CANDIDATES.find(candidate => existsSync(candidate))
if (chrome === undefined || !existsSync(chrome)) throw new Error(`no Chrome binary; tried ${CHROME_CANDIDATES.join(', ')} (pass --chrome=)`)
const entries = index.levels.filter(entry => (act === undefined || entry.act === act) && (levels === undefined || levels.has(entry.levelId)))
if (entries.length === 0) throw new Error('no index entry matches the filters')
const coverageProblems: string[] = []
if (act === undefined && levels === undefined) {
const present = new Set(entries.map(entry => entry.levelId))
const absent: number[] = []
for (let levelId = 1; levelId <= LEVEL_COUNT; levelId += 1) if (!present.has(levelId)) absent.push(levelId)
if (absent.length > 0) coverageProblems.push(`the pack has no map for level ids ${absent.join(', ')}`)
}
const server = await createServer({
root: ROOT,
configFile: join(ROOT, 'vite.config.ts'),
logLevel: 'error',
clearScreen: false,
server: { host: '127.0.0.1', port: 5600 + Math.floor(Math.random() * 300), strictPort: false },
})
await server.listen()
const address = server.httpServer?.address()
if (address === null || address === undefined || typeof address === 'string') throw new Error('the dev server has no TCP address')
const context: AuditContext = { baseUrl: `http://127.0.0.1:${String(address.port)}`, packDir, packPath, outDir, timeoutMs, shots }
console.log(`auditing ${String(entries.length)} maps of ${packPath} (${String(new Set(entries.map(entry => entry.levelId)).size)} level ids) with ${String(jobs)} browsers; dev server ${context.baseUrl}; Chrome ${chrome}`)
const browsers: Browser[] = []
const verdicts: Verdict[] = []
const started = Date.now()
try {
for (let i = 0; i < Math.min(jobs, entries.length); i += 1) browsers.push(await Browser.launch(chrome))
const queue = [...entries]
const report = (verdict: Verdict): void => {
verdicts.push(verdict)
const tail = verdict.ok ? `${(verdict.ms / 1000).toFixed(1)} s` : verdict.problems.join(' ; ')
console.log(`[${String(verdicts.length).padStart(3)}/${String(entries.length)}] ${verdict.ok ? 'ok ' : 'FAIL'} ${verdict.label.padEnd(44)} ${tail}`)
}
const run = async (browser: Browser, entry: IndexEntry): Promise<void> => {
try {
report(await auditEntry(browser, entry, context))
} catch (error) {
report({
label: entry.label, act: entry.act, levelId: entry.levelId, ok: false,
problems: [`audit error: ${(error as Error).message}`], state: null, spawn: null, screen: null, screenshot: null, ms: 0,
knownAbsent: [],
})
}
}
// One page first: it makes the dev server transform the modules once instead of in every tab.
await run(browsers[0]!, queue.shift()!)
await Promise.all(browsers.map(async browser => {
for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) await run(browser, entry)
}))
} finally {
for (const browser of browsers) await browser.close()
await server.close()
}
const order = new Map(entries.map((entry, at) => [entry.label, at]))
verdicts.sort((a, b) => order.get(a.label)! - order.get(b.label)!)
const failed = verdicts.filter(verdict => !verdict.ok)
const failedIds = new Set(failed.map(verdict => verdict.levelId))
const knownAbsent = [...new Set(verdicts.flatMap(verdict => verdict.knownAbsent))].sort()
writeFileSync(join(outDir, 'report.json'), JSON.stringify({
pack: packPath,
generated: new Date().toISOString(),
maps: verdicts.length,
levelIds: new Set(verdicts.map(verdict => verdict.levelId)).size,
failedMaps: failed.length,
coverageProblems,
knownAbsent,
verdicts: verdicts.map(verdict => ({ ...verdict, knownAbsent: verdict.knownAbsent.length })),
}, null, 1))
console.log(`\n${String(verdicts.length - failed.length)}/${String(verdicts.length)} maps passed, ${String(new Set(verdicts.map(v => v.levelId)).size - failedIds.size)}/${String(new Set(verdicts.map(v => v.levelId)).size)} level ids clean, in ${((Date.now() - started) / 1000).toFixed(0)} s; report ${join(outDir, 'report.json')}`)
if (knownAbsent.length > 0) console.log(`known-absent requests (reported, not failed): ${knownAbsent.join(', ')}`)
for (const problem of coverageProblems) console.log(`COVERAGE: ${problem}`)
for (const verdict of failed) console.log(`FAIL ${verdict.label}: ${verdict.problems.join(' ; ')}`)
if (failed.length > 0 || coverageProblems.length > 0) process.exitCode = 1
}
await main()

37
scripts/drlg-fixtures.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* Regenerates the committed native-oracle fixtures of tests/drlg-act1-oracle.test.ts.
*
* Each fixture is the D2MOO oracle's dump (tools/d2moo-oracle, schema 3) of the Act I outdoor levels
* for one game seed: difficulty 0, one shared act, every room activated through
* DRLGROOMTILE_AddRoomMapTiles. The test diffs the TypeScript port against them without needing the
* native binary.
*
* Usage: npx tsx scripts/drlg-fixtures.ts [output-directory]
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { gzipSync } from 'node:zlib'
import { act1OutdoorLevelIds } from '../src/game/drlg/drlg-map.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { fsDrlgSource, ORACLE_DATA, runOracle } from './lib/drlg-oracle.ts'
/** The seeds the test pins: an arbitrary one plus the three Act I layouts the bake ships (0x5eed0100 + v). */
export const DRLG_FIXTURE_SEEDS: readonly number[] = [0x12345678, 0x5eed0100, 0x5eed0101, 0x5eed0102]
function main(): void {
const outDir = resolve(process.argv[2] ?? join(import.meta.dirname, '../tests/fixtures/d2moo-oracle'))
mkdirSync(outDir, { recursive: true })
const levels = act1OutdoorLevelIds(loadDrlgTables(fsDrlgSource(ORACLE_DATA)))
for (const seed of DRLG_FIXTURE_SEEDS) {
const { doc } = runOracle({ seed, levels, difficulty: 0, isolated: false })
if (doc.schema !== 3) throw new Error(`oracle wrote schema ${String(doc.schema)}, expected 3 (rebuild it with tools/d2moo-oracle/build.sh)`)
const json = JSON.stringify(doc)
const path = join(outDir, `act1-seed-${(seed >>> 0).toString(16).padStart(8, '0')}.json.gz`)
const gz = gzipSync(json, { level: 9 })
writeFileSync(path, gz)
console.log(`${path}: ${(json.length / 1048576).toFixed(1)} MB JSON, ${(gz.length / 1024).toFixed(0)} KB gzip`)
}
}
if (import.meta.url === `file://${process.argv[1]}`) main()

View File

@ -0,0 +1,165 @@
/**
* MPQ-backed game data for the DRLG port (offline bake and tooling).
*
* The port reads its inputs synchronously, exactly like D2Common does from the mounted archives
* (see src/game/drlg/drlg-source.ts). MPQ reads are asynchronous, so this module preloads every file
* the port can open for one act and serves them from memory:
* - the compiled tables `data\global\excel\<name>.bin` ({@link DRLG_TABLE_NAMES});
* - every DS1 the tables reference (LvlPrest File1..6, LvlSub File), for all acts, because level
* generation behind warps (sub_6FD77BB0 -> DRLG_InitLevel) opens presets of any level it reaches;
* - the DT1 libraries of the act's level types (LvlTypes File1..32, loaded by
* DRLGROOMTILE_LoadDT1FilesForRoom for activated rooms) plus the hardcoded libraries of
* DRLGROOMTILE_LoadDT1FilesForRoom (DrlgRoomTile.cpp:1249-1256) and DRLG_AllocDrlg
* (DrlgDrlg.cpp:47-76).
* Anything else is not loaded and reading it throws (memoryDrlgSource), so a gap fails the bake
* instead of silently changing the generated map. The file list is the same one
* scripts/extract-d2moo-tables.ts extracts for the native oracle (the DT1 list restricted to the act),
* and the reads use the game's archive priority, so the bake and the oracle consume identical bytes.
*/
import { decodeDt1, type Dt1 } from '../../src/formats/dt1.ts'
import { cell, type D2Table } from '../../src/game/acts.ts'
import type { DrlgLevelInput } from '../../src/game/drlg/drlg-map.ts'
import { loadDrlgTables, type DrlgDataSource, type DrlgTableName, type DrlgTables } from '../../src/game/drlg/drlg-tables.ts'
import { DRLG_TABLE_NAMES, drlgReferencedDs1Files, memoryDrlgSource, normalizeDrlgPath } from '../../src/game/drlg/drlg-source.ts'
/**
* DT1 libraries D2Common opens by hardcoded name (full archive paths, as the port requests them):
* the three universal libraries of DRLGROOMTILE_LoadDT1FilesForRoom (Blank, InvisWal, Warp) and the
* act libraries of DRLG_AllocDrlg.
*/
export const DRLG_HARDCODED_DT1_FILES: readonly string[] = [
'DATA\\GLOBAL\\TILES\\Act1\\Outdoors\\Blank.dt1',
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\InvisWal.dt1',
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1',
'DATA\\GLOBAL\\TILES\\Act1\\Town\\Floor.dt1',
'DATA\\GLOBAL\\TILES\\Act2\\Town\\Ground.dt1',
'DATA\\GLOBAL\\TILES\\ACT3\\Kurast\\sets.dt1',
]
/** Reads one archive member (backslash path, case-insensitive), throwing when it does not exist. */
export type ArchiveReader = (mpqPath: string) => Promise<Uint8Array>
export interface DrlgMpqData {
/** Synchronous source over the preloaded bytes (throws for anything not preloaded). */
readonly source: DrlgDataSource
/** The parsed tables (immutable; pass them to createDrlgEnv to avoid re-parsing). */
readonly tables: DrlgTables
/** Archive paths of the preloaded DS1 and DT1 files. */
readonly ds1Files: readonly string[]
readonly dt1Files: readonly string[]
}
/**
* DT1 libraries of every level type of one act: the non-placeholder LvlTypes File columns of the rows
* whose `Act` (D2LvlTypesTxt.nAct, 1-based as in LvlTypes.txt) is `act1Based`, plus
* {@link DRLG_HARDCODED_DT1_FILES}.
*/
export function drlgActDt1Files(t: DrlgTables, act1Based: number): string[] {
if (!Number.isInteger(act1Based) || act1Based < 1 || act1Based > 5) {
throw new Error(`drlgActDt1Files: act ${act1Based} is not 1..5`)
}
const out = new Map<string, string>()
const add = (path: string): void => {
const key = normalizeDrlgPath(path)
if (!out.has(key)) out.set(key, path)
}
let rows = 0
for (const rec of t.lvlTypes) {
if (rec.nAct !== act1Based) continue
rows += 1
// DATATBLS_LoadLevelTypesTxt leaves names of one character or less ("0", "") unprefixed: they are
// unused slots that DRLGROOMTILE_LoadDT1FilesForRoom never opens.
for (const f of rec.szFile) if (f.length > 1) add(f)
}
if (rows === 0) throw new Error(`drlgActDt1Files: LvlTypes has no rows for act ${act1Based}`)
for (const f of DRLG_HARDCODED_DT1_FILES) add(f)
return [...out.values()].sort((a, b) => normalizeDrlgPath(a).localeCompare(normalizeDrlgPath(b)))
}
/** The compiled tables `data\global\excel\<name>.bin` of {@link DRLG_TABLE_NAMES}. */
async function readDrlgTableBytes(read: ArchiveReader): Promise<Map<DrlgTableName, Uint8Array>> {
const tableBytes = new Map<DrlgTableName, Uint8Array>()
for (const name of DRLG_TABLE_NAMES) {
tableBytes.set(name, await read(`data\\global\\excel\\${name}.bin`))
}
return tableBytes
}
/**
* Load only the compiled tables: enough to map a generated level (src/game/drlg/drlg-map.ts), not to
* generate one.
*
* @param read - archive reader with the game's priority (e.g. MountedArchives.read).
*/
export async function loadDrlgMpqTables(read: ArchiveReader): Promise<DrlgTables> {
return loadDrlgTables(memoryDrlgSource(await readDrlgTableBytes(read), new Map()))
}
/**
* Preload the DRLG inputs for one act from the archives.
*
* @param read - archive reader with the game's priority (e.g. MountedArchives.read).
* @param act1Based - the act whose level types' DT1 libraries are loaded (1..5).
*/
export async function loadDrlgMpqData(read: ArchiveReader, act1Based: number): Promise<DrlgMpqData> {
const tableBytes = await readDrlgTableBytes(read)
const tables = loadDrlgTables(memoryDrlgSource(tableBytes, new Map()))
const files = new Map<string, Uint8Array>()
const ds1Files = drlgReferencedDs1Files(tables)
const dt1Files = drlgActDt1Files(tables, act1Based)
for (const path of [...ds1Files, ...dt1Files]) {
const key = normalizeDrlgPath(path)
if (!files.has(key)) files.set(key, await read(path))
}
return { source: memoryDrlgSource(tableBytes, files), tables, ds1Files, dt1Files }
}
/**
* SuperUniques.txt `Superunique` ids in record order: the landmark ids of SuperUnique preset units
* (src/game/drlg/drlg-map.ts `DrlgMapContext.superUniqueIds`).
*
* The compiled table has no record for the text file's `Expansion` separator row, so the text rows are
* filtered the same way; each remaining row's `hcIdx` must equal its record index and the row count
* must equal the compiled table's.
*
* @param superuniques - SuperUniques.txt.
* @param tables - the compiled tables the DRLG uses.
* @returns the ids, by SuperUniques record.
*/
export function drlgSuperUniqueIds(superuniques: D2Table, tables: DrlgTables): string[] {
const ids = superuniques.rows
.filter(row => cell(superuniques, row, 'Superunique') !== 'Expansion')
.map((row, at) => {
const hcIdx = Number(cell(superuniques, row, 'hcIdx'))
if (hcIdx !== at) throw new Error(`SuperUniques.txt record ${String(at)} has hcIdx ${String(hcIdx)}`)
return cell(superuniques, row, 'Superunique').trim()
})
if (ids.length !== tables.nSuperUniquesTxtRecordCount) {
throw new Error(`SuperUniques.txt has ${String(ids.length)} records, superuniques.bin ${String(tables.nSuperUniquesTxtRecordCount)}`)
}
return ids
}
/**
* The decoded DT1 libraries the tiles of one generated level come from, read through a synchronous
* DRLG source (the same bytes the generator loaded).
*
* @param input - the level, from src/game/drlg/drlg-map.ts `drlgLevelInputFromDump`.
* @param source - the DRLG data source the level was generated from.
* @param cache - decoded libraries by normalized path, shared across levels.
* @returns the libraries the level's tiles name, by normalized path.
*/
export function drlgLevelDt1s(input: DrlgLevelInput, source: DrlgDataSource, cache: Map<string, Dt1>): Map<string, Dt1> {
const out = new Map<string, Dt1>()
for (const t of input.tiles) {
if (t.tile === null || out.has(t.tile.library)) continue
let lib = cache.get(t.tile.library)
if (lib === undefined) {
lib = decodeDt1(source.readFile(t.tile.library))
cache.set(t.tile.library, lib)
}
out.set(t.tile.library, lib)
}
return out
}

View File

@ -34,7 +34,7 @@ import { performance } from 'node:perf_hooks'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, ACT_TOWNS } from '../src/game/acts.ts'
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, townLevelForAct, ACT_TOWNS } from '../src/game/acts.ts'
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
@ -58,6 +58,20 @@ import { generateWilderness } from '../src/game/wilderness.ts'
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { generatePreset } from '../src/game/preset.ts'
import type { PresetPiece } from '../src/game/preset.ts'
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import type { DrlgEntrance, DrlgLevelInput } from '../src/game/drlg/drlg-map.ts'
import { dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import type { DrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { WorldVariants } from '../src/game/act-variants.ts'
import type { ActLayout, VariantEntry, VariantIndex } from '../src/game/act-variants.ts'
import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/drlg-mpq-source.ts'
/**
* An entrance a generator reports: the wilderness generator's, or the DRLG adapter's (seams carry a
* span) with the display label the bake gives it.
*/
type GeneratorEntrance = WildernessEntrance | (DrlgEntrance & { readonly label: string })
export interface MazeWarp {
readonly room: number
readonly direction: 'up' | 'down'
@ -437,6 +451,128 @@ function levelNameOf(levelId: number): string {
return worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
}
/* ------------------------------------------------------------------------- *
* DRLG port (Act I outdoor levels)
* ------------------------------------------------------------------------- */
/** Copies the bake makes of every act the DRLG port generates, one game seed each. */
const DRLG_ACT_VARIANTS = 3
/**
* The game seed copy `variant` of `act` is generated from.
*
* D2 generates a whole act from one seed (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections ->
* DRLG_InitLevel), so the seed belongs to the (act, copy) pair, never to a level. Act I's three are the
* seeds of the committed oracle fixtures (tests/fixtures/d2moo-oracle/act1-seed-5eed010{0,1,2}), so every
* baked copy is one the native D2MOO oracle generates identically.
*
* @param act - act number (1..5).
* @param variant - copy number (0-based, the index entries' `actVariant`).
* @returns the game seed.
*/
function actGameSeed(act: number, variant: number): number {
return (0x5eed_0000 + act * 0x100 + variant) >>> 0
}
/**
* The levels of an act the DRLG port generates: Act I's outdoor levels (Levels.txt DrlgType 3). The
* other acts still bake through the legacy generators.
*
* @param t - the DRLG tables.
* @param act - act number (1..5).
* @returns level ids, ascending.
*/
function drlgLevelsOf(t: DrlgTables, act: number): readonly number[] {
return act === 1 ? act1OutdoorLevelIds(t) : []
}
/** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */
interface DrlgActCopy {
readonly layout: ActLayout
readonly inputs: ReadonlyMap<number, DrlgLevelInput>
}
let drlgTablesPromise: Promise<DrlgTables> | undefined
/**
* The DRLG's compiled tables, loaded once per thread (the adapter maps a level with them).
*
* @returns the tables.
*/
function drlgTables(): Promise<DrlgTables> {
drlgTablesPromise ??= loadDrlgMpqTables(member => archives.read(member))
return drlgTablesPromise
}
/**
* Generate every copy of every DRLG act that has a level to bake, on the main thread.
*
* One DRLG run per copy, as in D2: the act's outdoor levels, their seams and the town's gate come out
* of the same run, so the copy is recorded as a whole (`index.json` `actLayouts`) and the runtime uses
* one copy per act (src/game/act-variants.ts). The whole act is generated even when a filter asks for a
* single level, because no level of it exists without the others.
*
* @param wanted - whether the bake's filters select a level.
* @returns the copies by act; acts without a selected DRLG level are absent.
*/
async function generateDrlgActs(wanted: (act: number, levelId: number) => boolean): Promise<Map<number, DrlgActCopy[]>> {
const out = new Map<number, DrlgActCopy[]>()
const t = await drlgTables()
for (let act = 1; act <= 5; act += 1) {
const levels = drlgLevelsOf(t, act)
if (!levels.some(levelId => wanted(act, levelId))) continue
const data = await loadDrlgMpqData(member => archives.read(member), act)
const town = townLevelForAct(act)
const copies: DrlgActCopy[] = []
for (let variant = 0; variant < DRLG_ACT_VARIANTS; variant += 1) {
const gameSeed = actGameSeed(act, variant)
const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels)
const townFile = dump.act.find(level => level.id === town)?.preset?.map?.file
if (townFile === undefined || townFile === '') {
throw new Error(`act ${String(act)} seed 0x${gameSeed.toString(16)}: the generated act picked no DS1 for its town (level ${String(town)})`)
}
copies.push({
layout: { variant, gameSeed, levels: [...levels], presets: [{ levelId: town, ds1: townFile.replace(/\//g, '\\') }] },
inputs: new Map(levels.map(levelId => [levelId, drlgLevelInputFromDump(dump, levelId, data.tables)])),
})
console.log(`DRLG act ${String(act)} copy ${String(variant)} (seed 0x${gameSeed.toString(16)}): town ${townFile}`)
}
out.set(act, copies)
}
return out
}
/**
* Check that every DRLG act copy in the (merged) index resolves the way the runtime resolves it: each
* generated level has exactly one copy per variant, the town file the copy fixed is baked, and no
* other copy of a generated level is left over (e.g. from a legacy bake merged in by a filtered run).
*
* @param index - the index about to be written.
*/
function validateActLayouts(index: VariantIndex<VariantEntry>): void {
for (const [actKey, layouts] of Object.entries(index.actLayouts ?? {})) {
const act = Number(actKey)
const generated = new Set(layouts.flatMap(layout => layout.levels))
for (const entry of index.levels) {
if (entry.act === act && entry.levelId !== undefined && generated.has(entry.levelId) && entry.actVariant === undefined) {
throw new Error(`index: ${entry.label} is a copy of DRLG level ${String(entry.levelId)} without an actVariant; bake the whole act`)
}
}
for (const layout of layouts) {
// A fresh selection per copy, pinned to it the way opening one of its levels pins it.
const variants = new WorldVariants(index)
const member = index.levels.find(entry => entry.act === act && entry.actVariant === layout.variant)
if (member === undefined) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no baked level belongs to it`)
variants.pin(member)
for (const levelId of [...layout.levels, ...layout.presets.map(preset => preset.levelId)]) {
if (variants.entryForLevel(levelId) === null) {
throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: level ${String(levelId)} is not baked`)
}
}
if (variants.startEntry(act) === null) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no start level`)
}
}
}
/**
* The border seams an outdoor level must cut, as the generator wants them.
*
@ -471,6 +607,8 @@ function gatesFor(levelId: number): PlannedGate[] {
* @param region - the level's main walkable region when there is a meaningful
* one, keeping the arrival point out of sealed pockets; undefined for
* generated levels, where the unstamped void would be the largest region.
* @param subTile - the warp's exact sub-tile when the generator knows it (the
* DRLG's UNIT_TILE preset unit); the anchor cell's centre otherwise.
* @returns the warp.
*/
function makeWarp(
@ -481,11 +619,12 @@ function makeWarp(
grid: IsoMapScene,
source: SceneWarp['source'],
region: WalkableRegion | undefined,
subTile?: { readonly x: number; readonly y: number },
): SceneWarp {
const warpId = edge.warps[0] ?? -1
const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId)
let x = cellToSubTile(cellX)
let y = cellToSubTile(cellY)
let x = subTile?.x ?? cellToSubTile(cellX)
let y = subTile?.y ?? cellToSubTile(cellY)
const fullSpan = Math.max(grid.gridWidth, grid.gridHeight)
if (region !== undefined && !triggerableFrom(grid, region, x, y, WARP_TRIGGER_SUBTILES)) {
const snapped = nearestWalkable(grid, x, y, fullSpan, region)
@ -543,9 +682,16 @@ function makeWarp(
* @param kind - how it was built.
* @param ds1 - the assembled map, for its warp markers.
* @param grid - the baked collision map.
* @param entrances - `stats.entrances` from the wilderness generator, if any.
* @param entrances - `stats.entrances` from the wilderness generator, or the
* DRLG adapter's entrances, if any.
* @param mazeWarps - `stats.warps` from the maze generator, if any.
* @param waypointCells - waypoint objects found in the artwork, in sub-tiles.
* @param mazeWaypoints - `stats.waypoints` from the maze generator, if any.
* @param options - `exactLinks`: the generator reports every seam and warp of
* the level exactly (the DRLG port). Nothing is then inferred: warp markers,
* staircase rooms, border scans and fallback warps are not used, every seam
* must carry the stretch of edge it opens on, and any disagreement with the
* world graph throws.
* @returns the links, plus anything the graph wanted that could not be placed.
*/
function buildSceneLinks(
@ -553,11 +699,13 @@ function buildSceneLinks(
kind: LevelKind,
ds1: Ds1,
grid: IsoMapScene,
entrances: readonly WildernessEntrance[],
entrances: readonly GeneratorEntrance[],
mazeWarps: readonly MazeWarp[],
waypointCells: readonly { x: number; y: number }[],
mazeWaypoints?: readonly MazeWaypoint[],
options: { readonly exactLinks?: boolean } = {},
): SceneLinks {
const exactLinks = options.exactLinks === true
const outEntrances: SceneEntrance[] = []
const outWarps: SceneWarp[] = []
const unplaced: { toLevelId: number; reason: string }[] = []
@ -617,8 +765,10 @@ function buildSceneLinks(
(townDistance.get(toLevelId) ?? Number.MAX_SAFE_INTEGER) < here ? 'up' : 'down'
// 1. The staircases the artwork declares. Authoritative for every kind of
// level, because the slot identifies the destination outright.
for (const tile of findWarpTiles(ds1)) {
// level, because the slot identifies the destination outright. Skipped
// when the generator reports its warps exactly: the DRLG port hands over
// the UNIT_TILE warp units D2 itself creates for them.
for (const tile of exactLinks ? [] : findWarpTiles(ds1)) {
const edge = warpEdgeBySlot.get(tile.slot)
// A marker for a slot this level does not use: the piece was drawn for a
// level that connects there and reused here. Ignore it rather than invent
@ -633,6 +783,36 @@ function buildSceneLinks(
// 2. Outdoor levels: the generator already cut the seams, and knows where the
// interior presets it stamped sit.
for (const anchor of entrances) {
const span = 'span' in anchor ? anchor.span : undefined
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0 && span !== undefined) {
// A DRLG seam: the level link names the stretch of this edge that opens
// onto the neighbour (the open border block, or the whole shared edge
// for the town and the Monastery approach), so the walkable gap is
// searched there and nowhere else.
const range = { from: span.from * SUB_TILES_PER_TILE, to: span.to * SUB_TILES_PER_TILE }
const opening = findBorderOpening(grid, anchor.side, region, undefined, range)
if (opening === null) {
throw new Error(`level ${String(levelId)}: no reachable opening on the ${anchor.side} edge in tiles `
+ `${String(span.from)}..${String(span.to)} for the seam to level ${String(anchor.toLevelId)}`)
}
if (placedSeams.has(anchor.toLevelId)) {
throw new Error(`level ${String(levelId)}: two seams lead to level ${String(anchor.toLevelId)}`)
}
outEntrances.push({
toLevelId: anchor.toLevelId,
side: anchor.side,
label: anchor.label,
x: opening.x,
y: opening.y,
arriveX: opening.arriveX,
arriveY: opening.arriveY,
})
placedSeams.add(anchor.toLevelId)
continue
}
if (exactLinks && anchor.kind === 'gate') {
throw new Error(`level ${String(levelId)}: seam to level ${String(anchor.toLevelId)} has no side and span`)
}
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) {
// The anchor the generator hands over is the border *piece*, and the
// centre of that cell is wall; on some outdoor variants the border
@ -657,11 +837,43 @@ function buildSceneLinks(
}
// A preset's mouth: the cave, tower or crypt that opens off this level.
const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId)
const warp = 'warp' in anchor ? anchor.warp : undefined
if (exactLinks) {
if (edge === undefined) {
throw new Error(`level ${String(levelId)}: the generator has a warp to level ${String(anchor.toLevelId)}, the world graph does not`)
}
if (placedWarps.has(edge.to)) {
throw new Error(`level ${String(levelId)}: two warps lead to level ${String(anchor.toLevelId)}`)
}
if (warp === undefined) {
throw new Error(`level ${String(levelId)}: warp to level ${String(anchor.toLevelId)} has no warp unit position`)
}
}
if (edge === undefined || placedWarps.has(edge.to)) continue
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region))
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region,
warp === undefined ? undefined : { x: warp.subX, y: warp.subY }))
placedWarps.add(anchor.toLevelId)
}
if (exactLinks) {
// The generator's links and the world graph are two readings of the same
// Levels.txt rows (Vis0..7 / Warp0..7); any difference is a bug in one of
// them, never something to paper over with a guessed opening.
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to)) {
throw new Error(`level ${String(levelId)}: the world graph has a seam to level ${String(edge.to)}, the generator does not`)
}
if (edge.kind === 'warp' && !placedWarps.has(edge.to)) {
throw new Error(`level ${String(levelId)}: the world graph has a warp to level ${String(edge.to)}, the generator does not`)
}
}
for (const to of placedSeams) {
if (!edgesFrom(worldGraph, levelId).some(edge => edge.kind === 'seamless' && edge.to === to)) {
throw new Error(`level ${String(levelId)}: the generator has a seam to level ${String(to)}, the world graph does not`)
}
}
}
// 3. Mazes: staircase rooms, for pieces whose artwork carries no marker.
if (kind === 'maze') {
const leftover = [...edgesFrom(worldGraph, levelId)]
@ -799,17 +1011,29 @@ function buildSceneLinks(
}
}
// Waypoints. The artwork wins when it has one; otherwise the level gets a
// stand-in, because `DRLGOUTDOORS_SpawnAct12Waypoint` is not implemented and
// a waypoint the network knows about but the map does not show is worse than
// Waypoints. The artwork wins when it has one. The DRLG port's levels always
// do (DRLGOUTDOORS_SpawnAct12Waypoint puts the object in the level), so for
// them a missing or unreachable waypoint is a bug and throws. Legacy
// generators do not run that pass, so their levels get a stand-in: a
// waypoint the network knows about but the map does not show is worse than
// an approximate position.
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
const waypoints: SceneWaypoint[] = []
if (exactLinks && waypointId === 255 && waypointCells.length > 0) {
throw new Error(`level ${String(levelId)}: the generator placed a waypoint, Levels.txt has none`)
}
if (waypointId !== 255) {
if (exactLinks && waypointCells.length !== 1) {
throw new Error(`level ${String(levelId)}: the generator placed ${String(waypointCells.length)} waypoints for waypoint ${String(waypointId)}`)
}
const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined)
const spot = fromArt ?? findWaypointSpot(grid, region)
if (spot !== null && spot !== undefined) {
const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot
const reached = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region)
if (exactLinks && reached === null) {
throw new Error(`level ${String(levelId)}: waypoint at ${String(spot.x)},${String(spot.y)} is not next to the main walkable region`)
}
const arrive = reached ?? spot
waypoints.push({
waypointId,
x: spot.x,
@ -1054,30 +1278,11 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
return values
}
/**
* The LvlPrest families the legacy outdoor generator draws its pieces from, by level type. Act I is not
* here: its outdoor levels bake only through the DRLG port ('drlg' jobs, see generateDrlgActs).
*/
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Town 1 Transition',
'Act 1 - DOE Entrance',
'Act 1 - Cave Entrance',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - River',
'Act 1 - Bridge',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',
'Act 1 - Stone Fill',
'Act 1 - Cottages',
'Act 1 - Fallen Camp',
'Act 1 - Camp',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Ruin',
'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -1158,7 +1363,7 @@ function getWildernessPieces(levelTypeName: string, subType?: number, levelName?
}
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name)
const isBorder = /border|cliff|ravine/i.test(name)
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) })
@ -1211,11 +1416,14 @@ async function getPalette(paletteName: string): Promise<Uint8Array> {
interface SceneJob {
readonly jobId: number
readonly entry: LevelJob
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness'
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' | 'drlg'
readonly ds1Name?: string
/** Copy number: 1-based for the legacy generators, the 0-based act copy (`actVariant`) for 'drlg'. */
readonly variant?: number
readonly label: string
readonly seed: number
/** 'drlg' jobs: the level as the main thread's DRLG run of the act copy generated it. */
readonly drlg?: { readonly gameSeed: number; readonly input: DrlgLevelInput }
}
interface SceneResult {
@ -1249,12 +1457,20 @@ async function bakeDs1Variant(
* from the collision map instead.
*/
generatorLinks: {
readonly entrances: readonly WildernessEntrance[]
readonly entrances: readonly GeneratorEntrance[]
readonly mazeWarps: readonly MazeWarp[]
readonly mazeWaypoints?: readonly MazeWaypoint[]
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
readonly notes?: readonly string[] | undefined
readonly animSpeed?: number | undefined
/** The generator reports every seam and warp exactly (see buildSceneLinks). */
readonly exactLinks?: boolean
/**
* `'layout'`: every room, tile (style, sequence, type, DT1 library) and preset unit is D2's own
* (the DRLG port); only the variant within a tile's (style, sequence) is the renderer's pick.
* Unset: the generator approximates the level.
*/
readonly fidelity?: 'layout'
} = { entrances: [], mazeWarps: [] },
animSpeedOverride?: number,
): Promise<SceneResult> {
@ -1296,6 +1512,7 @@ async function bakeDs1Variant(
generatorLinks.mazeWarps,
waypointCells,
generatorLinks.mazeWaypoints,
{ exactLinks: generatorLinks.exactLinks === true },
)
const spawn = findIsoSpawn(scene, { warps: sceneLinks.warps, entrances: sceneLinks.entrances })
@ -1593,7 +1810,7 @@ async function bakeDs1Variant(
const sceneJson = {
version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
fidelity: entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation',
act: entry.act,
levelId: entry.levelId,
levelName,
@ -1832,6 +2049,45 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
)
}
if (job.type === 'drlg') {
const drlg = job.drlg
if (drlg === undefined || job.variant === undefined) {
throw new Error(`${job.label}: a DRLG job must carry its generated level and act copy`)
}
const t = await drlgTables()
// The libraries of the tiles D2 picked, for their (style, sequence, type) headers.
const dt1 = new Map<string, Dt1>()
for (const tile of drlg.input.tiles) {
if (tile.tile !== null && !dt1.has(tile.tile.library)) dt1.set(tile.tile.library, await libraryOf(tile.tile.library))
}
const map = buildDrlgLevelMap(drlg.input, { tables: t, dt1, superUniqueIds: drlgSuperUniqueIds(tables.superuniques, t) })
const libraries: Dt1[] = []
for (const name of map.dt1Names) libraries.push(await libraryOf(name))
const entrances: GeneratorEntrance[] = map.entrances.map(entrance => ({
...entrance,
label: entrance.kind === 'gate' ? `${levelNameOf(entrance.toLevelId)} Seam` : levelNameOf(entrance.toLevelId),
}))
const s = map.stats
const result = await bakeDs1Variant(
job.jobId,
job.entry, job.entry.name, palette, paletteName, map.dt1Names, libraries,
map.ds1, `generated:${job.label}`, job.label, job.seed,
{
entrances,
mazeWarps: [],
landmarks: map.landmarks,
exactLinks: true,
fidelity: 'layout',
notes: [
`DRLG port: act ${String(job.entry.act)} copy ${String(job.variant)}, game seed 0x${drlg.gameSeed.toString(16)}`,
`DRLG tiles: ${String(s.walls)} walls, ${String(s.floors)} floors, ${String(s.shadows)} shadows, `
+ `${String(s.withoutTile)} without a DT1 tile; units: ${String(s.objects)} objects, ${String(s.monsters)} monsters`,
],
},
)
return { ...result, indexEntry: { ...result.indexEntry, actVariant: job.variant } }
}
if (job.type === 'wilderness') {
const type = levelType(levelRow)
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
@ -1889,11 +2145,14 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
*
* @param filterLevels - optional filter set of level IDs.
* @param filterAct - optional filter for a specific act.
* @param drlgActs - the DRLG-generated act copies ({@link generateDrlgActs}); their levels bake one
* 'drlg' job per copy instead of going through the legacy wilderness generator.
* @returns ordered array of scene jobs with sequential jobIds (0..N-1).
*/
async function collectSceneJobs(
filterLevels: Set<number> | null,
filterAct: number | null,
drlgActs: ReadonlyMap<number, readonly DrlgActCopy[]>,
): Promise<SceneJob[]> {
const jobs: SceneJob[] = []
let jobId = 0
@ -1952,6 +2211,25 @@ async function collectSceneJobs(
})
}
} else if (entry.kind === 'wilderness') {
const copies = drlgActs.get(entry.act)
if (copies !== undefined && copies.some(copy => copy.layout.levels.includes(entry.levelId))) {
for (const copy of copies) {
const input = copy.inputs.get(entry.levelId)
if (input === undefined) {
throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`)
}
jobs.push({
jobId: jobId++,
entry,
type: 'drlg',
variant: copy.layout.variant,
label: `${entry.slug}-var${String(copy.layout.variant + 1)}`,
seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1,
drlg: { gameSeed: copy.layout.gameSeed, input },
})
}
continue
}
const type = levelType(levelRow)
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const pieces = await getWildernessPieces(type.name, subType, entry.name)
@ -1972,6 +2250,19 @@ async function collectSceneJobs(
}
}
// Every selected level of a generated act must bake from the DRLG copies: a level whose Levels.txt
// kind disagreed with the DRLG's DrlgType would otherwise vanish from the act without a word.
for (const [act, copies] of drlgActs) {
for (const levelId of copies[0]?.layout.levels ?? []) {
if (filterLevels !== null && !filterLevels.has(levelId)) continue
if (filterAct !== null && act !== filterAct) continue
const baked = jobs.filter(job => job.type === 'drlg' && job.entry.levelId === levelId).length
if (baked !== copies.length) {
throw new Error(`DRLG level ${String(levelId)} of act ${String(act)} has ${String(baked)} bake jobs, expected ${String(copies.length)}`)
}
}
}
return jobs
}
@ -2129,7 +2420,9 @@ async function main(): Promise<void> {
await mkdir(join(outDir, `act${String(act)}`), { recursive: true })
}
const sceneJobs = await collectSceneJobs(filterLevels, filterAct)
const drlgActs = await generateDrlgActs((act, levelId) =>
(filterLevels === null || filterLevels.has(levelId)) && (filterAct === null || act === filterAct))
const sceneJobs = await collectSceneJobs(filterLevels, filterAct, drlgActs)
if (sceneJobs.length === 0) {
console.log('No scenes matched filter criteria.')
return
@ -2164,12 +2457,20 @@ async function main(): Promise<void> {
skippedMissingArtSpawns += res.skippedMissingArtSpawns
}
// The DRLG act copies the generated levels belong to (src/game/act-variants.ts reads them).
const actLayouts: Record<string, ActLayout[]> = {}
for (const [act, copies] of drlgActs) actLayouts[String(act)] = copies.map(copy => copy.layout)
if (Object.keys(actLayouts).length > 0) index.actLayouts = actLayouts
const indexPath = join(outDir, 'index.json')
if (filterLevel !== undefined || filterAct !== null) {
try {
const { readFile } = await import('node:fs/promises')
const existingRaw = await readFile(indexPath, 'utf8')
const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> }
const existing = JSON.parse(existingRaw) as {
levels?: Array<{ label?: string }>
actLayouts?: Record<string, ActLayout[]>
}
if (Array.isArray(existing.levels)) {
const updatedByLabel = new Map<string, unknown>()
for (const item of index.levels as Array<{ label: string }>) {
@ -2190,11 +2491,16 @@ async function main(): Promise<void> {
}
index.levels = merged
}
// An act this run generated replaces its old copies whole; other acts keep theirs.
if (existing.actLayouts !== undefined) index.actLayouts = { ...existing.actLayouts, ...actLayouts }
} catch {
// No existing index.json or unreadable; write fresh index
}
}
// Outside the merge's catch-all: an act copy the runtime could not resolve must fail the bake.
validateActLayouts(index as unknown as VariantIndex<VariantEntry>)
// Atomic writes from main thread
await writeJsonAtomic(indexPath, index)

View File

@ -0,0 +1,390 @@
/**
* Side-by-side proof that the baked Act I outdoor maps are the maps D2MOO generates.
*
* For one game seed, the native D2MOO oracle (tools/d2moo-oracle) and the TypeScript port
* (src/game/drlg, fed from the MPQs exactly as scripts/pack-act-assets.ts feeds it) each generate
* Act I. Both dumps then take the same road to pixels: drlgLevelInputFromDump -> buildDrlgLevelMap
* (the bake's adapter) -> buildIsoMapScene (the bake's scene builder) -> the renderers below. For
* every requested level it writes
*
* <name>_d2moo_blueprint.png / <name>_port_blueprint.png / <name>_xor_blueprint.png
* <name>_d2moo_iso.png / <name>_port_iso.png / <name>_xor_iso.png
*
* and fails (exit 1) unless both XOR images are empty. The iso XOR is computed at full resolution
* on palette indices and the shadow layer; the written iso images are downscaled previews (a block
* of the XOR preview is lit when any of its pixels differs).
*
* It also prints the DT1-mask ambiguity audit of every level (auditDrlgDt1Ambiguity: references whose
* candidate tiles under their cell's DT1 mask are variants, or span more than one library) and writes
* everything to summary.json.
*
* Blueprint colours (one 3x3-pixel block per sub-tile): walkable sub-tile green, blocked sub-tile
* tan, cell without any tile near-black, 8x8-tile room grid dark lines, level links and warps red,
* preset monsters (landmarks) yellow.
*
* Usage:
* npx tsx scripts/render-drlg-compare.ts [--copy=0] [--seed=0x5eed0100] [--levels=2,3,4]
* [--out=DIR] [--scale=4]
*
* --copy picks the bake's act copy (0..2): the game seed of that copy (actGameSeed in
* pack-act-assets.ts) and the scene seed the bake gives its levels. --seed overrides the game seed.
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { decodePal } from '../src/formats/pal.ts'
import { cell, parseTable } from '../src/game/acts.ts'
import { buildIsoMapScene, type IsoDraw, type IsoMapScene } from '../src/game/d2map.ts'
import { dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
import { act1OutdoorLevelIds, auditDrlgDt1Ambiguity, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgDt1Ambiguity, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from './lib/drlg-mpq-source.ts'
import { firstDiff, formatDiff, ORACLE_BIN, runOracle } from './lib/drlg-oracle.ts'
import { encodeIndexedPng, encodeRgbaPng } from './png.ts'
const ROOT = resolve(import.meta.dirname, '..')
const ARCHIVE_DIR = join(ROOT, 'samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
const LEVELS_TXT = 'data\\global\\excel\\Levels.txt'
const ACT1_PALETTE = 'data\\global\\palette\\act1\\pal.dat'
/** Blueprint pixels per sub-tile. */
const BLUEPRINT_SCALE = 3
/** Tiles per side of an outdoor room (DRLG grid cell), for the blueprint grid lines. */
const ROOM_TILES = 8
/** Shadow layer darkening in the iso previews. */
const SHADOW_FACTOR = 0.5
/** Blueprint palette indices. */
const BP = { void: 0, walkable: 1, blocked: 2, grid: 3, link: 4, landmark: 5 } as const
const BLUEPRINT_PALETTE: readonly (readonly [number, number, number])[] = [
[12, 16, 22], [36, 78, 44], [198, 158, 86], [24, 40, 30], [240, 70, 70], [255, 226, 90],
]
/** The bake's act-copy game seed (scripts/pack-act-assets.ts actGameSeed, act 1). */
function act1GameSeed(copy: number): number {
return (0x5eed_0000 + 1 * 0x100 + copy) >>> 0
}
/** The seed the bake gives the scene of a DRLG level copy (scripts/pack-act-assets.ts, job type 'drlg'). */
function bakeSceneSeed(levelId: number, copy: number): number {
return 0x5eed_1000 + levelId * 10 + copy + 1
}
function slug(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
}
interface Args {
readonly copy: number
readonly seed: number
readonly levels: readonly number[]
readonly out: string
readonly scale: number
}
function parseArgs(argv: readonly string[]): Args {
const known = new Set(['copy', 'seed', 'levels', 'out', 'scale'])
const values = 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(k => `--${k}=`).join(' ')})`)
values.set(match[1]!, match[2]!)
}
const copy = Number(values.get('copy') ?? '0')
if (!Number.isInteger(copy) || copy < 0 || copy > 2) throw new Error('--copy must be 0, 1 or 2')
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 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 }
}
// ---------------------------------------------------------------------------------------------
// Renderers
interface IsoRender {
readonly width: number
readonly height: number
/** Palette index per pixel (0 where nothing was drawn). */
readonly index: Uint8Array
/** 1 where the shadow layer darkens the pixel. */
readonly shade: Uint8Array
/** 1 where anything was drawn. */
readonly drawn: Uint8Array
}
/**
* Paint a scene the way the engine layers it: floors, then shadows (darkening), then walls and roofs
* in their painter's order. Full resolution, palette indices.
*/
function renderIso(scene: IsoMapScene): IsoRender {
const width = scene.widthPx
const height = scene.heightPx
const index = new Uint8Array(width * height)
const shade = new Uint8Array(width * height)
const drawn = new Uint8Array(width * height)
const paint = (draws: readonly IsoDraw[], mode: 'opaque' | 'shadow'): void => {
for (const draw of draws) {
const frame = scene.frames[draw.frameIndex]
if (frame === undefined) throw new Error(`draw at cell ${String(draw.cellX)},${String(draw.cellY)} names frame ${String(draw.frameIndex)} of ${String(scene.frames.length)}`)
for (let fy = 0; fy < frame.height; fy += 1) {
const py = draw.y + fy
if (py < 0 || py >= height) continue
for (let fx = 0; fx < frame.width; fx += 1) {
const px = draw.x + fx
if (px < 0 || px >= width) continue
const at = fy * frame.width + fx
if (frame.mask[at] !== 1) continue
const to = py * width + px
if (mode === 'shadow') {
shade[to] = 1
} else {
index[to] = frame.indices[at]!
shade[to] = 0
drawn[to] = 1
}
}
}
}
}
paint(scene.floors, 'opaque')
paint(scene.shadows, 'shadow')
paint(scene.walls, 'opaque')
paint(scene.roofs, 'opaque')
return { width, height, index, shade, drawn }
}
/** A downscaled RGBA preview of an iso render (nearest sample of each block). */
function isoPreview(render: IsoRender, rgb: Uint8Array, scale: number): Uint8Array {
const width = Math.ceil(render.width / scale)
const height = Math.ceil(render.height / scale)
const pixels = new Uint8Array(width * height * 4)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const from = (y * scale) * render.width + x * scale
const to = (y * width + x) * 4
if (render.drawn[from] !== 1) {
pixels[to + 3] = 255
continue
}
const factor = render.shade[from] === 1 ? SHADOW_FACTOR : 1
const colour = render.index[from]! * 3
pixels[to] = Math.round(rgb[colour]! * factor)
pixels[to + 1] = Math.round(rgb[colour + 1]! * factor)
pixels[to + 2] = Math.round(rgb[colour + 2]! * factor)
pixels[to + 3] = 255
}
}
return encodeRgbaPng({ width, height, pixels })
}
/** Differing pixels of two iso renders, and a downscaled mask of them (a block lights up if any pixel in it differs). */
function isoXor(a: IsoRender, b: IsoRender, scale: number): { differing: number; png: Uint8Array } {
if (a.width !== b.width || a.height !== b.height) {
throw new Error(`iso renders differ in size: ${String(a.width)}x${String(a.height)} vs ${String(b.width)}x${String(b.height)}`)
}
const width = Math.ceil(a.width / scale)
const height = Math.ceil(a.height / scale)
const pixels = new Uint8Array(width * height)
let differing = 0
for (let y = 0; y < a.height; y += 1) {
for (let x = 0; x < a.width; x += 1) {
const at = y * a.width + x
if (a.index[at] !== b.index[at] || a.shade[at] !== b.shade[at] || a.drawn[at] !== b.drawn[at]) {
differing += 1
pixels[Math.floor(y / scale) * width + Math.floor(x / scale)] = 1
}
}
}
const palette = new Uint8Array(256 * 3)
palette.set([255, 0, 255], 3)
return { differing, png: encodeIndexedPng({ width, height, pixels, palette }) }
}
interface Blueprint {
readonly width: number
readonly height: number
readonly pixels: Uint8Array
}
/** The level's collision, cells, room grid, links and landmarks, one block per sub-tile. */
function renderBlueprint(map: DrlgLevelMap, scene: IsoMapScene): Blueprint {
const { gridWidth, gridHeight } = scene
const width = gridWidth * BLUEPRINT_SCALE
const height = gridHeight * BLUEPRINT_SCALE
const pixels = new Uint8Array(width * height).fill(BP.void)
const block = (subX: number, subY: number, colour: number): void => {
for (let dy = 0; dy < BLUEPRINT_SCALE; dy += 1) {
for (let dx = 0; dx < BLUEPRINT_SCALE; dx += 1) {
const px = subX * BLUEPRINT_SCALE + dx
const py = subY * BLUEPRINT_SCALE + dy
if (px >= 0 && px < width && py >= 0 && py < height) pixels[py * width + px] = colour
}
}
}
for (let subY = 0; subY < gridHeight; subY += 1) {
for (let subX = 0; subX < gridWidth; subX += 1) {
const cell = map.ds1.cells[Math.floor(subY / SUB_TILES_PER_TILE)]?.[Math.floor(subX / SUB_TILES_PER_TILE)]
const hasTile = cell !== undefined && (cell.floors.some(f => f.prop1 !== 0) || cell.walls.some(w => w.prop1 !== 0))
if (!hasTile) continue
block(subX, subY, scene.blocked[subY * gridWidth + subX] === 0 ? BP.walkable : BP.blocked)
}
}
const step = ROOM_TILES * SUB_TILES_PER_TILE * BLUEPRINT_SCALE
for (let py = 0; py < height; py += step) pixels.fill(BP.grid, py * width, (py + 1) * width)
for (let px = 0; px < width; px += step) for (let py = 0; py < height; py += 1) pixels[py * width + px] = BP.grid
const marker = (subX: number, subY: number, colour: number): void => {
for (let dy = -1; dy <= 1; dy += 1) for (let dx = -1; dx <= 1; dx += 1) block(subX + dx, subY + dy, colour)
}
const centre = Math.floor(SUB_TILES_PER_TILE / 2)
for (const entrance of map.entrances) {
if (entrance.warp !== undefined) marker(entrance.warp.subX, entrance.warp.subY, BP.link)
else marker(entrance.x * SUB_TILES_PER_TILE + centre, entrance.y * SUB_TILES_PER_TILE + centre, BP.link)
}
for (const landmark of map.landmarks) marker(landmark.subX, landmark.subY, BP.landmark)
return { width, height, pixels }
}
function blueprintPng(blueprint: Blueprint): Uint8Array {
const palette = new Uint8Array(256 * 3)
BLUEPRINT_PALETTE.forEach((colour, at) => palette.set(colour, at * 3))
return encodeIndexedPng({ width: blueprint.width, height: blueprint.height, pixels: blueprint.pixels, palette })
}
function blueprintXor(a: Blueprint, b: Blueprint): { differing: number; png: Uint8Array } {
if (a.width !== b.width || a.height !== b.height) {
throw new Error(`blueprints differ in size: ${String(a.width)}x${String(a.height)} vs ${String(b.width)}x${String(b.height)}`)
}
const pixels = new Uint8Array(a.pixels.length)
let differing = 0
for (let at = 0; at < pixels.length; at += 1) {
if (a.pixels[at] !== b.pixels[at]) {
pixels[at] = 1
differing += 1
}
}
const palette = new Uint8Array(256 * 3)
palette.set([255, 0, 255], 3)
return { differing, png: encodeIndexedPng({ width: a.width, height: a.height, pixels, palette }) }
}
// ---------------------------------------------------------------------------------------------
// Driver
interface Side {
readonly map: DrlgLevelMap
readonly scene: IsoMapScene
readonly iso: IsoRender
readonly blueprint: Blueprint
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2))
if (!existsSync(ORACLE_BIN)) throw new Error(`${ORACLE_BIN} is missing: run ./tools/d2moo-oracle/build.sh`)
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
if (missing.length > 0) throw new Error(`the 1.13c archives are required in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
mkdirSync(args.out, { recursive: true })
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
const data: DrlgMpqData = await loadDrlgMpqData(member => archives.read(member), 1)
const superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
const palette = decodePal(await archives.read(ACT1_PALETTE))
const levelsTxt = parseTable(await archives.read(LEVELS_TXT))
const levelName = (levelId: number): string => {
const row = levelsTxt.rows.find(r => Number(cell(levelsTxt, r, 'Id')) === levelId)
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
return cell(levelsTxt, row, 'LevelName')
}
// 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) {
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(', ')}]`)
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 })
const dumpDiff = firstDiff(oracle, port, '$')
console.log(dumpDiff === null ? 'dumps: identical' : `dumps: DIFFER at ${formatDiff(dumpDiff)}`)
const dt1Cache = new Map<string, Dt1>()
const library = (name: string): Dt1 => {
let dt1 = dt1Cache.get(name)
if (dt1 === undefined) {
dt1 = decodeDt1(data.source.readFile(name))
dt1Cache.set(name, dt1)
}
return dt1
}
const side = (dump: DrlgDump, levelId: 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))
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 failed = dumpDiff !== null
for (const levelId of args.levels) {
const name = slug(levelName(levelId))
const d2moo = side(oracle, levelId)
const ours = side(port, levelId)
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))
ambiguityTotal.refs += ambiguity.refs
ambiguityTotal.variantGroups += ambiguity.variantGroups
ambiguityTotal.crossLibrary += ambiguity.crossLibrary
ambiguityTotal.crossLibraryLevelWide += ambiguity.crossLibraryLevelWide
const write = (file: string, png: Uint8Array): void => writeFileSync(join(args.out, `${name}_${file}.png`), png)
write('d2moo_blueprint', blueprintPng(d2moo.blueprint))
write('port_blueprint', blueprintPng(ours.blueprint))
write('xor_blueprint', bp.png)
write('d2moo_iso', isoPreview(d2moo.iso, palette.rgb, args.scale))
write('port_iso', isoPreview(ours.iso, palette.rgb, args.scale))
write('xor_iso', iso.png)
const s = ours.map.stats
const row = {
levelId,
name,
canvas: `${String(ours.map.ds1.width)}x${String(ours.map.ds1.height)}`,
walls: s.walls,
floors: s.floors,
shadows: s.shadows,
objects: s.objects,
monsters: s.monsters,
entrances: ours.map.entrances.length,
landmarks: ours.map.landmarks.length,
isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`,
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)}`)
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
}
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}`)
if (failed) process.exitCode = 1
}
await main()

View File

@ -1,276 +0,0 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { decodePal } from '../src/formats/pal.ts'
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
import { buildIsoMapScene, COLLIDE_WALL, COLLIDE_DOOR } from '../src/game/d2map.ts'
import { encodeIndexedPng } from './png.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
type WildernessResult,
} from '../src/game/wilderness.ts'
const d2DataDir = '/usr/local/google/home/taodao/d2-data'
const artifactDir = '/usr/local/google/home/taodao/.gemini/jetski/brain/a4f9163c-a011-4161-b169-7f361b1ed57e'
mkdirSync(artifactDir, { recursive: true })
async function main() {
const mpqNames = ['Patch_D2.mpq', 'd2exp.mpq', 'd2data.mpq']
const archives = new MountedArchives()
const rawMpqs: MpqArchive[] = []
for (const name of mpqNames) {
const archive = await MpqArchive.open(await fileSource(join(d2DataDir, name)))
archives.add(name, archive)
rawMpqs.push(archive)
}
async function readMpqFile(mpqPath: string): Promise<Uint8Array | null> {
const norm = mpqPath.replace(/\//g, '\\')
for (const mpq of rawMpqs) {
const entry = mpq.find(norm)
if (entry !== undefined) {
return await mpq.read(entry)
}
}
return null
}
const actTables = await loadActTables(archives)
const lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const val = cell(table, row, `File${String(slot)}`)
if (val === '' || val === '0') continue
levels.push(await loadDs1(val))
}
return levels
}
const WILDERNESS_PIECE_FAMILIES = [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
]
const act1Pieces: WildernessPiece[] = []
for (const row of actTables.lvlprest.rows) {
const name = cell(actTables.lvlprest, row, 'Name')
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
const levels = await rowDs1s(actTables.lvlprest, row)
if (levels.length === 0) continue
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
}
const act1Subs: WildernessSubstitution[] = []
for (const row of lvlsubTable.rows) {
const subType = Number(cell(lvlsubTable, row, 'Type'))
if (subType !== 0 && subType !== 1 && subType !== 6) continue
const file = cell(lvlsubTable, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
act1Subs.push({
name: cell(lvlsubTable, row, 'Name'),
type: subType,
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsubTable, row, 'BordType')),
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
levels,
})
}
// Load DT1 libraries for Act 1 Wilderness (LevelType = 2) + 3 universal DT1s
const dt1Libraries: Dt1[] = []
const universalFiles = [
'data\\global\\tiles\\act1\\outdoors\\blank.dt1',
'data\\global\\tiles\\act1\\outdoors\\inviswal.dt1',
'data\\global\\tiles\\act1\\outdoors\\warp.dt1',
]
for (const uf of universalFiles) {
const bytes = await readMpqFile(uf)
if (bytes) dt1Libraries.push(decodeDt1(bytes))
}
const lvlTypesBytes = await readMpqFile('data\\global\\excel\\LvlTypes.txt')
const lvlTypesLines = new TextDecoder().decode(lvlTypesBytes!).split(/\r?\n/)
const lvlTypesHeader = lvlTypesLines[0]!.split('\t')
const lvlTypeIdIdx = lvlTypesHeader.indexOf('Id')
const fileCols = Array.from({ length: 32 }, (_, i) => lvlTypesHeader.indexOf(`File ${i + 1}`))
const typeRow = lvlTypesLines.slice(1).map(l => l.split('\t')).find(c => Number(c[lvlTypeIdIdx]) === 2)
if (typeRow) {
for (let i = 0; i < 32; i++) {
const colIdx = fileCols[i]!
const fName = (typeRow[colIdx] ?? '').trim()
if (!fName || fName === '0') continue
const bytes = await readMpqFile(`data\\global\\tiles\\${fName}`)
if (bytes) dt1Libraries.push(decodeDt1(bytes))
}
}
const seed = 0x12345678
console.log(`Generating Blood Moor with new TypeScript wilderness generator (Seed: 0x${seed.toString(16)})...`)
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
dt1Libraries,
})
const ds1 = result.level
console.log(`Generated Level DS1: ${ds1.width}x${ds1.height} tiles, ${ds1.objects.length} objects`)
const scene = buildIsoMapScene(ds1, dt1Libraries, seed)
console.log(`Scene built: ${scene.gridWidth}x${scene.gridHeight} subtiles, ${scene.widthPx}x${scene.heightPx} px, ${scene.frames.length} frames`)
const palBytes = await readMpqFile('data\\global\\palette\\act1\\pal.dat')
const palette = decodePal(palBytes!)
// 1) Render High-Res Blueprint
const scale = 3
const mapW = scene.gridWidth * scale
const mapH = scene.gridHeight * scale
const mapPixels = new Uint8Array(mapW * mapH)
const mapPal = new Uint8Array(256 * 3)
const setColor = (idx: number, r: number, g: number, b: number) => {
mapPal[idx * 3] = r
mapPal[idx * 3 + 1] = g
mapPal[idx * 3 + 2] = b
}
setColor(1, 12, 16, 22) // Void
setColor(2, 42, 68, 46) // Outdoor 8x8 Grid Line
setColor(3, 36, 78, 44) // Walkable Grass Moorland
setColor(4, 198, 158, 86) // Solid Wall / Cliff / Fence / River Bank
setColor(5, 176, 132, 78) // Dirt Road Path
setColor(6, 240, 84, 84) // Preset Landmark Outline
setColor(7, 88, 166, 255) // River Boundary
setColor(8, 255, 230, 100) // DS1 Object
mapPixels.fill(1)
for (let sy = 0; sy < scene.gridHeight; sy++) {
for (let sx = 0; sx < scene.gridWidth; sx++) {
const cellX = Math.floor(sx / 5)
const cellY = Math.floor(sy / 5)
const c = ds1.cells[cellY]?.[cellX]
const hasFloor = c && c.floors.some(f => f.prop1 !== 0)
const hasWall = c && c.walls.some(w => w.prop1 !== 0)
if (!hasFloor && !hasWall) continue
const mask = scene.collisionMasks[sy * scene.gridWidth + sx]!
let color = 3
const isRoad = c?.floors.some(f => f.style === 1 || f.style === 30 || (f.style === 0 && f.sequence > 0))
if (isRoad) color = 5
if ((mask & COLLIDE_WALL) !== 0) color = 4
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
mapPixels[(sy * scale + dy) * mapW + (sx * scale + dx)] = color
}
}
}
}
// Draw 8x8 Room Grid Lines
for (let by = 0; by <= ds1.height; by += 8) {
const py = by * 5 * scale
if (py >= 0 && py < mapH) {
for (let px = 0; px < mapW; px++) {
mapPixels[py * mapW + px] = 2
}
}
}
for (let bx = 0; bx <= ds1.width; bx += 8) {
const px = bx * 5 * scale
if (px >= 0 && px < mapW) {
for (let py = 0; py < mapH; py++) {
mapPixels[py * mapW + px] = 2
}
}
}
// Draw Objects
for (const obj of ds1.objects) {
const ox = obj.x * scale
const oy = obj.y * scale
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const px = ox + dx
const py = oy + dy
if (px >= 0 && px < mapW && py >= 0 && py < mapH) {
mapPixels[py * mapW + px] = 8
}
}
}
}
const blueprintOut = join(artifactDir, 'new_ts_blood_moor_blueprint.png')
writeFileSync(blueprintOut, encodeIndexedPng({ width: mapW, height: mapH, pixels: mapPixels, palette: mapPal }))
console.log(`Saved Blueprint: ${blueprintOut}`)
// 2) Render Full Isometric DT1 Scene
const isoScale = scene.widthPx > 4000 ? 2 : 1
const isoW = Math.ceil(scene.widthPx / isoScale)
const isoH = Math.ceil(scene.heightPx / isoScale)
const isoPixels = new Uint8Array(isoW * isoH)
const drawPass = (draws: typeof scene.floors) => {
for (const d of draws) {
const frame = scene.frames[d.frameIndex]
if (!frame) continue
for (let fy = 0; fy < frame.height; fy += isoScale) {
const py = Math.floor((d.y + fy) / isoScale)
if (py < 0 || py >= isoH) continue
for (let fx = 0; fx < frame.width; fx += isoScale) {
const px = Math.floor((d.x + fx) / isoScale)
if (px < 0 || px >= isoW) continue
const idx = frame.indices[fy * frame.width + fx]!
if (idx !== 0) isoPixels[py * isoW + px] = idx
}
}
}
}
drawPass(scene.floors)
drawPass(scene.shadows)
drawPass(scene.walls)
drawPass(scene.roofs)
const isoOut = join(artifactDir, 'new_ts_blood_moor_iso.png')
writeFileSync(isoOut, encodeIndexedPng({ width: isoW, height: isoH, pixels: isoPixels, palette: palette.rgb }))
console.log(`Saved Isometric Render: ${isoOut}`)
console.log(`Dimensions: ${isoW}x${isoH} px (scale 1/${isoScale})`)
}
void main()

View File

@ -3,10 +3,12 @@
* Diablo II Web DRLG Engine: Seed Sensitivity & Determinism Profiler
*
* Requirements:
* 1. Stress-test seed sensitivity across 50 distinct random seeds for all outdoor levels across Acts 1..5.
* 2. Assert Act 1 baseline hashes under master seeds 0x301cd095, 0x416d61c5, 0xdeadbeef match baseline table bit-for-bit.
* 3. Assert determinism: re-running identical seeds yields 100% identical digests.
* 4. Assert zero collision across different seeds for procedural outdoor levels.
* 1. Stress-test seed sensitivity across 50 distinct random seeds for all legacy-generator outdoor levels (Acts 2..5).
* 2. Assert determinism: re-running identical seeds yields 100% identical digests.
* 3. Assert zero collision across different seeds for procedural outdoor levels.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
@ -72,22 +74,11 @@ function canonicalHash(level: Ds1): string {
}
/* ------------------------------------------------------------------------- *
* Canonical Master Seeds and Act 1 Baseline
* Canonical Master Seeds
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
}
/* ------------------------------------------------------------------------- *
* 50 Distinct Adversarial & Random Seeds
* ------------------------------------------------------------------------- */
@ -123,15 +114,6 @@ function generate50Seeds(): number[] {
* ------------------------------------------------------------------------- */
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -201,7 +183,7 @@ async function runStressTest(): Promise<void> {
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
piecesCache.set(levelTypeName, pieces)
@ -251,11 +233,17 @@ async function runStressTest(): Promise<void> {
}
const wildernessLevels: WildernessLevelMeta[] = []
const drlgPortLevels: number[] = []
for (const row of tables.levels.rows) {
if (cell(tables.levels, row, 'DrlgType') !== '3') continue
const id = Number(cell(tables.levels, row, 'Id'))
const name = cell(tables.levels, row, 'Name')
const act = Number(cell(tables.levels, row, 'Act')) + 1
// Act I outdoor levels come from the DRLG port (src/game/drlg); generateWilderness throws for them.
if (act === 1) {
drlgPortLevels.push(id)
continue
}
const typeId = cell(tables.levels, row, 'LevelType')
const typeRow = tables.lvltypes.rows.find(r => cell(tables.lvltypes, r, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
@ -280,9 +268,10 @@ async function runStressTest(): Promise<void> {
})
}
console.log(`Discovered ${wildernessLevels.length} outdoor wilderness levels across Acts 1..5:`)
console.log(`Discovered ${wildernessLevels.length} legacy-generator outdoor wilderness levels across Acts 2..5:`)
console.log(`- Procedural outdoor levels: ${wildernessLevels.filter(l => l.isProcedural).length}`)
console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}\n`)
console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}`)
console.log(`(Act I outdoor levels ${drlgPortLevels.join(', ')} are the DRLG port's; see tests/drlg-act1-*.test.ts)\n`)
const seeds50 = generate50Seeds()
console.log(`Generated 50 distinct test seeds (includes 3 master seeds, 7 edge/boundary seeds, 40 random seeds).`)
@ -305,49 +294,10 @@ async function runStressTest(): Promise<void> {
}
// ---------------------------------------------------------------------------
// TEST 1: Act 1 Baseline Hashes under Master Seeds
// TEST 1 & 2: Seed Sensitivity & Determinism across 50 seeds for every legacy-generator level
// ---------------------------------------------------------------------------
console.log('--- [Test 1] Act 1 Baseline Hashes under Master Seeds (0x301cd095, 0x416d61c5, 0xdeadbeef) ---')
const act1Levels = wildernessLevels.filter(l => l.act === 1)
for (const level of act1Levels) {
const expected = ACT1_BASELINE_HASHES[level.id]
check(expected !== undefined, `Act 1 level ${level.id} (${level.name}) must have a baseline hash table entry`)
if (!expected) continue
const pieces = await getWildernessPieces(level.levelTypeName)
const subs = await getSubstitutions(level.subType)
const shrineSubs = await getSubstitutions(level.subShrine)
const actualHashes: string[] = []
for (let i = 0; i < MASTER_SEEDS.length; i += 1) {
const seed = MASTER_SEEDS[i]
const res = generateWilderness({
levelId: level.id,
levelName: level.name,
levelTypeName: level.levelTypeName,
sizeX: level.sizeX,
sizeY: level.sizeY,
subType: level.subType,
subTheme: Math.max(0, level.subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
})
const digest = canonicalHash(res.level)
actualHashes.push(digest)
const expDigest = expected[i]
check(digest === expDigest, `Level ${level.id} (${level.name}) seed 0x${seed.toString(16)} hash ${digest} must match baseline ${expDigest}`)
}
console.log(` Act 1 Level ${String(level.id).padStart(2)}: ${level.name.padEnd(20)} -> [${actualHashes.join(', ')}] (100% match)`)
}
console.log(' -> Test 1 Complete.\n')
// ---------------------------------------------------------------------------
// TEST 2 & 3: Seed Sensitivity & Determinism across 50 seeds for all 31 levels
// ---------------------------------------------------------------------------
console.log('--- [Test 2 & 3] 50-Seed Sensitivity & Deterministic Re-run Invariance ---')
console.log('Testing all 31 wilderness levels across 50 distinct seeds...\n')
console.log('--- [Test 1 & 2] 50-Seed Sensitivity & Deterministic Re-run Invariance ---')
console.log(`Testing all ${wildernessLevels.length} wilderness levels across 50 distinct seeds...\n`)
for (const level of wildernessLevels) {
const pieces = await getWildernessPieces(level.levelTypeName)
@ -392,7 +342,7 @@ async function runStressTest(): Promise<void> {
const uniqueDigests = new Set(digests)
if (level.isProcedural) {
// Assertion 4: Zero collision across different seeds for procedural outdoor levels
// Assertion 3: Zero collision across different seeds for procedural outdoor levels
const collisionCount = digests.length - uniqueDigests.size
check(
collisionCount === 0,
@ -428,11 +378,11 @@ async function runStressTest(): Promise<void> {
process.exit(1)
}
console.log('\n✅ ALL 4 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:')
console.log('1. Seed sensitivity: 50 distinct random seeds tested across all outdoor levels.')
console.log('2. Act 1 regression guard: 8/8 levels match baseline table bit-for-bit under 3 master seeds.')
console.log('3. Determinism: 1,550 duplicate generation pairs produced 100% identical FNV-1a digests.')
console.log('4. Zero collision: 28/28 procedural levels produced 50/50 unique digests (0 collisions).')
const proceduralCount = wildernessLevels.filter(l => l.isProcedural).length
console.log('\n✅ ALL 3 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:')
console.log(`1. Seed sensitivity: ${seeds50.length} distinct random seeds tested across all ${wildernessLevels.length} legacy-generator outdoor levels.`)
console.log(`2. Determinism: ${wildernessLevels.length * seeds50.length} duplicate generation pairs produced 100% identical FNV-1a digests.`)
console.log(`3. Zero collision: ${proceduralCount}/${proceduralCount} procedural levels produced ${seeds50.length}/${seeds50.length} unique digests (0 collisions).`)
console.log('======================================================================\n')
}

View File

@ -11,7 +11,10 @@
* - Assert barricades span vertically across Y in [8..H-8] when width >= height with military gate at hubY.
* - Assert gates are open (no wall collision mask) and walkable.
* - Assert full end-to-end corridor traversability through the barricade gates.
* 3. Verify Act 1 baseline hashes in tests/wilderness-seed-sensitivity.test.ts remain 100% bit-identical.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts (bit-exact against native D2MOO) and tests/drlg-act1-invariants.test.ts
* cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
@ -20,79 +23,20 @@ import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
import { loadActTables, cell, tileMemberPath } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
} from '../src/game/wilderness.ts'
/* ------------------------------------------------------------------------- *
* FNV-1a Canonical Map Hasher
* ------------------------------------------------------------------------- */
class Hasher {
private hash = 2166136261
push(value: number): this {
const word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) {
hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
}
return hasher.digest
}
/* ------------------------------------------------------------------------- *
* Canonical Master Seeds & Act 1 Baseline
* Canonical Master Seeds
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
}
/* ------------------------------------------------------------------------- *
* Seed Generation (60 distinct seeds)
* ------------------------------------------------------------------------- */
@ -169,15 +113,6 @@ function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
* ------------------------------------------------------------------------- */
const WILDERNESS_PIECE_FAMILIES: Record<string, string[]> = {
'Act 1 - Wilderness': [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
@ -213,9 +148,7 @@ async function main(): Promise<void> {
// Load MPQ archives if present
let mpqPiecesAct4Mesa: WildernessPiece[] | null = null
let mpqPiecesAct4Lava: WildernessPiece[] | null = null
let mpqPiecesAct1Wilderness: WildernessPiece[] | null = null
let d2Tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } | null = null
let d2Lvlsub: D2Table | null = null
let d2Archives: MountedArchives | null = null
const samplePath = 'samples/d2/d2data.mpq'
@ -235,7 +168,6 @@ async function main(): Promise<void> {
lvltypes: actTables.lvltypes,
lvlprest: actTables.lvlprest,
}
d2Lvlsub = parseTable(await d2Archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
@ -267,7 +199,7 @@ async function main(): Promise<void> {
if (familyKey === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(d2Tables!.lvlprest, row)
if (levels.length === 0) continue
const isBorder = familyKey === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
@ -275,8 +207,7 @@ async function main(): Promise<void> {
mpqPiecesAct4Mesa = await getPieces('Act 4 - Mesa')
mpqPiecesAct4Lava = await getPieces('Act 4 - Lava')
mpqPiecesAct1Wilderness = await getPieces('Act 1 - Wilderness')
console.log(`✓ Loaded MPQ ground-truth pieces: Act 4 Mesa (${String(mpqPiecesAct4Mesa.length)}), Lava (${String(mpqPiecesAct4Lava.length)}), Act 1 (${String(mpqPiecesAct1Wilderness.length)})\n`)
console.log(`✓ Loaded MPQ ground-truth pieces: Act 4 Mesa (${String(mpqPiecesAct4Mesa.length)}), Lava (${String(mpqPiecesAct4Lava.length)})\n`)
}
const piecesAct4Mesa = mpqPiecesAct4Mesa ?? makeMockBorderPieces('Act 4')
@ -710,105 +641,6 @@ async function main(): Promise<void> {
console.log(` ✓ Horizontal/Square Corridor ${String(dim.sizeX)}x${String(dim.sizeY)}: 100% assertions passed across all ${String(seeds.length)} seeds.`)
}
/* ----------------------------------------------------------------------- *
* SECTION 3: Act 1 Baseline Hash Invariance Check
* ----------------------------------------------------------------------- */
console.log('\n----------------------------------------------------------------------')
console.log('🔒 SECTION 3: Act 1 Baseline Hash Invariance Check (Bit-for-Bit)')
console.log('----------------------------------------------------------------------')
if (d2Tables !== null && d2Lvlsub !== null && d2Archives !== null && mpqPiecesAct1Wilderness !== null) {
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await d2Archives!.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of d2Lvlsub!.rows) {
if (Number(cell(d2Lvlsub!, row, 'Type')) !== type) continue
const file = cell(d2Lvlsub!, row, 'File')
if (file === '' || file === '0') continue
rows.push({
name: cell(d2Lvlsub!, row, 'Name'),
type,
gridSize: Number(cell(d2Lvlsub!, row, 'GridSize')) || 1,
bordType: Number(cell(d2Lvlsub!, row, 'BordType')),
dt1Mask: Number(cell(d2Lvlsub!, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(d2Lvlsub!, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(d2Lvlsub!, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(d2Lvlsub!, row, `Max${String(i)}`)) || 0),
levels: [await loadDs1(file)],
})
}
return rows
}
const act1Levels = [
{ id: 2, name: 'Blood Moor' },
{ id: 3, name: 'Cold Plains' },
{ id: 4, name: 'Stony Field' },
{ id: 5, name: 'Dark Wood' },
{ id: 6, name: 'Black Marsh' },
{ id: 7, name: 'Tamoe Highland' },
{ id: 17, name: 'Burial Grounds' },
{ id: 39, name: 'Secret Cow Level' },
]
for (const lvl of act1Levels) {
const expectedHashes = ACT1_BASELINE_HASHES[lvl.id]
assert(expectedHashes !== undefined, `Level ${String(lvl.id)} must have expected baseline hashes`)
const levelRow = d2Tables.levels.rows.find(row => Number(cell(d2Tables!.levels, row, 'Id')) === lvl.id)!
const typeId = cell(d2Tables.levels, levelRow, 'LevelType')
const typeRow = d2Tables.lvltypes.rows.find(row => cell(d2Tables!.lvltypes, row, 'Id') === typeId)
const levelTypeName = typeRow ? cell(d2Tables.lvltypes, typeRow, 'Name') : ''
const sizeX = Number(cell(d2Tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(d2Tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(d2Tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(d2Tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(d2Tables.levels, levelRow, 'SubTheme'))
const subs = await buildSubstitutions(subType)
const shrineSubs = await buildSubstitutions(subShrine)
const actualHashes: string[] = []
for (let i = 0; i < MASTER_SEEDS.length; i += 1) {
const seed = MASTER_SEEDS[i]!
const result = generateWilderness({
levelId: lvl.id,
levelName: lvl.name,
levelTypeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces: mpqPiecesAct1Wilderness,
substitutions: subs,
shrineSubstitutions: shrineSubs,
})
const hash = canonicalHash(result.level)
actualHashes.push(hash)
const match = hash === expectedHashes![i]
assert(
match,
`Level ${String(lvl.id)} (${lvl.name}) seed 0x${seed.toString(16)}: hash ${hash} must match expected ${expectedHashes![i]}`,
)
}
console.log(` ✓ Act 1 Level ${String(lvl.id).padStart(2, ' ')} (${lvl.name}): hashes [${actualHashes.join(', ')}] match baseline 100% bit-for-bit`)
}
} else {
console.log(' ⚠️ MPQ archive not loaded; skipping bit-for-bit Act 1 baseline check (covered by vitest suite).')
}
/* ----------------------------------------------------------------------- *
* SUMMARY
* ----------------------------------------------------------------------- */

View File

@ -7,23 +7,19 @@
* - Numerically compute P^2 / A for continuous polar contour and discrete rasterized tile cells.
* - Assert P^2 / A > 4*pi * 1.15 holds 100% of the time (zero violations).
* 2. Stress-test jitterFrac: sample 10,000 draws and assert all values strictly lie in [base - delta, base + delta].
* 3. Verify Act 1 baseline hashes in tests/wilderness-seed-sensitivity.test.ts remain 100% bit-identical.
* 4. Verify Act 4 Lava Fissure flood-fill reachability >= 90%.
* 5. Verify Act 5 Transverse Barricade palisade orientation and choke-point gate clearance.
* 3. Verify Act 4 Lava Fissure flood-fill reachability >= 90%.
* 4. Verify Act 5 Transverse Barricade palisade orientation and choke-point gate clearance.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
} from '../src/game/wilderness.ts'
import {
act2OasisRadius,
@ -34,70 +30,6 @@ import {
} from '../src/game/wilderness-acts.ts'
import { Rng } from '../src/game/rng.ts'
/* ------------------------------------------------------------------------- *
* FNV-1a Canonical Map Hasher
* ------------------------------------------------------------------------- */
class Hasher {
private hash = 2166136261
push(value: number): this {
const word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) {
hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
}
return hasher.digest
}
/* ------------------------------------------------------------------------- *
* Canonical Master Seeds and Act 1 Baseline
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
}
/* ------------------------------------------------------------------------- *
* Mathematical Helpers: Continuous & Discrete Isoperimetric Quotients
* ------------------------------------------------------------------------- */
@ -628,157 +560,6 @@ async function main() {
console.log(` - Act 5 transverse barricade audited: ${barricadeChecks} checks (all perpendicular + open gate)`)
console.log(` ✓ Real level generation stress tests: 0 violations.\n`)
// -------------------------------------------------------------------------
// SECTION 5: Act 1 Baseline Hash Invariance (bit-identical)
// -------------------------------------------------------------------------
console.log('--- SECTION 5: ACT 1 BASELINE HASH INVARIANCE ---')
if (existsSync('samples/d2/d2data.mpq')) {
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 {
// Ignore missing optional archive
}
}
const actTables = await loadActTables(archives)
const tables = {
levels: actTables.levels,
lvltypes: actTables.lvltypes,
lvlprest: actTables.lvlprest,
}
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const value = cell(table, row, `File${String(slot)}`)
if (value === '' || value === '0') continue
levels.push(await loadDs1(value))
}
return levels
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
}
async function buildWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /\bBorder\b/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0),
levels: [await loadDs1(file)],
})
}
return rows
}
async function generateLevelForSeed(levelId: number, seed: number): Promise<Ds1> {
const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)!
const typeId = cell(tables.levels, levelRow, 'LevelType')
const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
const name = cell(tables.levels, levelRow, 'Name')
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await buildWildernessPieces(levelTypeName)
const subs = await buildSubstitutions(subType)
const shrineSubs = await buildSubstitutions(subShrine)
const result = generateWilderness({
levelId,
levelName: name,
levelTypeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
})
return result.level
}
const act1Levels = [
{ id: 2, name: 'Blood Moor' },
{ id: 3, name: 'Cold Plains' },
{ id: 4, name: 'Stony Field' },
{ id: 5, name: 'Dark Wood' },
{ id: 6, name: 'Black Marsh' },
{ id: 7, name: 'Tamoe Highland' },
{ id: 17, name: 'Burial Grounds' },
{ id: 39, name: 'Secret Cow Level' },
]
for (const lvl of act1Levels) {
const baseline = ACT1_BASELINE_HASHES[lvl.id]!
for (let sIdx = 0; sIdx < MASTER_SEEDS.length; sIdx += 1) {
const seed = MASTER_SEEDS[sIdx]!
const expectedHash = baseline[sIdx]!
const levelDs1 = await generateLevelForSeed(lvl.id, seed)
const digest = canonicalHash(levelDs1)
check(
digest === expectedHash,
`Act 1 Level ${lvl.id} (${lvl.name}) seed 0x${seed.toString(16)} hash mismatch: got ${digest}, expected ${expectedHash}`
)
}
console.log(` Act 1 Level ${String(lvl.id).padStart(2)}: ${lvl.name.padEnd(20)} | 3/3 baseline hashes 100% bit-identical`)
}
} else {
console.warn(' ⚠️ samples/d2/d2data.mpq not found; skipping MPQ Act 1 baseline check.')
}
// -------------------------------------------------------------------------
// FINAL REPORT
// -------------------------------------------------------------------------
@ -804,7 +585,6 @@ async function main() {
console.log(`4. Full Map Generation (100 seeds): 100% of water/lake bodies satisfy P^2/A > 4pi * 1.15.`)
console.log(`5. Act 4 Lava Fissure: 100% of tested maps maintain >= 90% flood-fill reachability.`)
console.log(`6. Act 5 Barricades: Transverse direction invariance & open choke-point gates verified.`)
console.log('7. Act 1 Regression Guard: 24/24 master seed hashes remain 100% bit-identical.')
console.log('======================================================================\n')
}

View File

@ -3,7 +3,10 @@
*
* 70 levels are randomly generated mazes (`Levels.txt.DrlgType == 1`) and 31 are
* wilderness (`== 3`); the other 35 are presets and are covered by
* `verify-acts.ts`. This script builds a generator request for each of those 101
* `verify-acts.ts`. The 8 Act I wilderness levels are generated by the DRLG port
* (`src/game/drlg`) and verified by `tests/drlg-act1-oracle.test.ts` (bit-exact
* against native D2MOO) and `tests/drlg-act1-invariants.test.ts`, so they are
* skipped here. This script builds a generator request for each of the other 93
* levels straight from the shipped tables, generates it with a fixed seed, and
* asserts the four things that would otherwise only show up as a broken map in an
* asset pack:
@ -395,31 +398,12 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
* Request builders
* ------------------------------------------------------------------------- */
/** Which `LvlPrest` name families belong to which wilderness level type. */
/**
* Which `LvlPrest` name families belong to which wilderness level type. Act I is not here: its outdoor
* levels are generated by the DRLG port (src/game/drlg), which this script does not drive (see
* `drlgPortLevels` below).
*/
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Town 1 Transition',
'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - River',
'Act 1 - Bridge',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',
'Act 1 - Stone Fill',
'Act 1 - Cottages',
'Act 1 - Fallen Camp',
'Act 1 - Camp',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Ruin',
'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -481,7 +465,7 @@ async function wildernessPieces(
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(archives, lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
@ -579,16 +563,20 @@ function mazeRow(levelId: number, levelName: string): readonly string[] | undefi
const mazeLevels: { id: number; name: string }[] = []
const wildLevels: { id: number; name: string }[] = []
/** Act I outdoor levels: generated by the D2MOO port (src/game/drlg), not by the legacy generator. */
const drlgPortLevels: number[] = []
for (const row of tables.levels.rows) {
const drlg = cell(tables.levels, row, 'DrlgType')
const id = Number(cell(tables.levels, row, 'Id'))
const name = cell(tables.levels, row, 'Name')
if (drlg === '1') mazeLevels.push({ id, name })
else if (drlg === '3' && cell(tables.levels, row, 'Act') === '0') drlgPortLevels.push(id)
else if (drlg === '3') wildLevels.push({ id, name })
}
console.log(`== generators ==`)
console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(wildLevels.length)} wilderness levels (DrlgType 3)`)
console.log(` Act I outdoor levels ${drlgPortLevels.join(', ')}: DRLG port, verified by tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts`)
let mazePassed = 0
let wildPassed = 0
@ -691,33 +679,6 @@ for (const { id, name } of wildLevels) {
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`)
if (id === 2) {
check(scene.walls.length > 500, label, `Blood Moor walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Corral') || s.name.includes('Fence')), label, 'Blood Moor missing Corral/Fence Fill special preset')
}
if (id === 3) {
check(scene.walls.length > 500, label, `Cold Plains walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Bishibosh')), label, 'Cold Plains missing Bishibosh preset')
}
if (id === 5) {
check(scene.walls.length > 500, label, `Dark Wood walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Inifus')), label, 'Dark Wood missing Inifus preset')
}
if (id === 39) {
check(scene.walls.length > 500, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Corral')), label, 'Moo Moo Farm missing Corral Fill special preset')
check(substitutions.some(s => s.name.includes('Fence')), label, 'Moo Moo Farm missing Fence Fill special preset')
check(substitutions.some(s => s.name.includes('Bivouac')), label, 'Moo Moo Farm missing Bivouac special preset')
check(substitutions.some(s => s.name.includes('Pond')), label, 'Moo Moo Farm missing Pond special preset')
}
if (type.name === 'Act 1 - Wilderness') {
const roadCells = Number(first.stats.roadCells ?? 0)
check(roadCells > 0, label, `Act 1 wilderness level must generate dirt roads (roadCells=${String(roadCells)})`)
}
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)

View File

@ -12,8 +12,9 @@
* 3. Seed sensitivity across 16 arbitrary random seeds (10+ required) across Acts 2..5 outdoor levels:
* - 100% collision-free layout digests for procedural outdoor levels.
* - Re-run determinism invariance.
* 4. Act 1 Bit-Identical Regression Guard:
* - All Act 1 outdoor levels remain 100% bit-identical to canonical baseline.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*/
import { existsSync } from 'node:fs'
@ -349,15 +350,6 @@ async function runSeedSensitivityTests(): Promise<void> {
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -376,7 +368,7 @@ async function runSeedSensitivityTests(): Promise<void> {
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
@ -488,44 +480,6 @@ async function runSeedSensitivityTests(): Promise<void> {
console.log(` ✓ Level ${String(lvl.id).padStart(3, ' ')} (${lvl.name.padEnd(24, ' ')} Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} collision-free digests | determinism OK`)
}
/* ------------------------------------------------------------------------- *
* Part 4: Act 1 Bit-Identical Regression Guard
* ------------------------------------------------------------------------- */
console.log('\n=================================================================')
console.log('PART 4: Act 1 Bit-Identical Regression Guard')
console.log('=================================================================')
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
}
const CANONICAL_TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
for (const levelId of act1Levels) {
const expected = ACT1_BASELINE_HASHES[levelId]!
const actual: string[] = []
for (const seed of CANONICAL_TEST_SEEDS) {
const level = await generateLevel(levelId, seed)
actual.push(canonicalHash(level))
}
const matches = JSON.stringify(actual) === JSON.stringify(expected)
assert(
matches,
`Act 1 Level ${levelId} matches baseline table 100% bit-for-bit: [${actual.join(', ')}] === [${expected.join(', ')}]`,
)
console.log(` ✓ Act 1 Level ${String(levelId).padStart(2, ' ')} bit-identical match: [${actual.join(', ')}]`)
}
}
async function main(): Promise<void> {

View File

@ -10,10 +10,12 @@
* This script closes that gap by replaying the runtime's own rules against the
* pack:
*
* - the variant picked for a destination is `variantSeed % candidates`, with
* `variantSeed = WORLD_VARIANT_SEED + levelId`, exactly as
* `loadRuntimeForLevel` does — one variant per level, so re-entering a
* level always gives the same map;
* - the copy loaded for a destination comes from `WorldVariants`
* (`src/game/act-variants.ts`), the same object `loadRuntimeForLevel` asks:
* one generated copy per act for DRLG acts (the town file that copy fixed
* included), one copy per level otherwise, preferring the copy whose opening
* faces the side the player arrives from; the walk starts where a fresh
* session starts (`startEntry`);
* - the landing spot is the destination's own opening back to where we came
* from, exactly as `travel` does;
* - a link fires when the player is within `SEAM_TRIGGER_SUBTILES` or
@ -35,7 +37,9 @@
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { WorldVariants, type ActLayout } from '../src/game/act-variants.ts'
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../src/game/level-links.ts'
import type { Side } from '../src/game/variants.ts'
const [packDir = 'samples/d2-packs'] = process.argv.slice(2)
@ -49,6 +53,8 @@ interface Link {
readonly label: string
readonly radius: number
readonly what: string
/** The edge a seam leaves by (passed on as `fromSide`, as `travel` does); absent for warps. */
readonly side?: Side
}
/** The fields of `scene.json` this script reads. */
@ -64,7 +70,7 @@ interface PackedScene {
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null
readonly entrances?: readonly {
toLevelId: number; side: string; label: string
toLevelId: number; side: Side; label: string
x: number; y: number; arriveX: number; arriveY: number
}[]
readonly warps?: readonly {
@ -81,11 +87,17 @@ interface IndexEntry {
readonly levelId: number
readonly path: string
readonly label: string
readonly slug?: string
readonly kind?: string
/** DS1 member (presets) or generator tag the map was baked from. */
readonly ds1?: string
/** DRLG act variant of a generated level. */
readonly actVariant?: number
}
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
levels: readonly IndexEntry[]
actLayouts?: Record<string, readonly ActLayout[]>
}
let checks = 0
@ -140,13 +152,19 @@ function blockedOf(scene: PackedScene): Uint8Array {
}
/**
* The spawn point, in sub-tiles.
* The sub-tile the player stands on at the spawn point.
*
* `scene.json` stores it in scene pixels, because that is the space the player
* entity lives in, while every link is in sub-tiles. This is the same inverse
* isometric projection `playerSubTile` applies in the scene: the half-cell is
* 16×8 pixels, so `x` and `y` come out of the sum and difference of the pixel
* offsets from the map's origin.
* `scene.json` stores the spawn in scene pixels, because that is the space the
* player entity lives in, while every link is in sub-tiles. The inverse
* isometric projection is the one `playerSubTile` applies in the scene: the
* half-cell is 16×8 pixels, so `x` and `y` come out of the sum and difference
* of the pixel offsets from the map's origin.
*
* The result is floored, as the collision the player moves against floors it
* (`isBlockedAt` in `src/game/d2map.ts`). Spawns sit at sub-tile centres
* (`n + 0.5`), where rounding picks the neighbouring sub-tile: the Rogue
* Encampment's TownS1 spawn is the centre of the open sub-tile (123,196), while
* (124,197) is part of a wall.
*
* @param scene - the packed scene.
* @returns the spawn sub-tile, or null when the map has no spawn.
@ -155,22 +173,21 @@ function spawnSubTile(scene: PackedScene): { x: number; y: number } | null {
if (scene.spawn == null) return null
const dx = ((scene.spawn[0] ?? 0) - scene.originX) / 16
const dy = ((scene.spawn[1] ?? 0) - scene.originY) / 8
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
return { x: Math.floor((dy + dx) / 2), y: Math.floor((dy - dx) / 2) }
}
/** Mirrors `WORLD_VARIANT_SEED` in `act-scene.ts`. */
const WORLD_VARIANT_SEED = 0x5eed_3000
/** The runtime's copy chooser, over this pack (`loadRuntimeForLevel` builds the same one). */
const variants = new WorldVariants(index)
/**
* Pick the variant the runtime would load.
* Pick the copy the runtime would load.
*
* @param levelId - the level wanted.
* @param fromSide - the edge of the previous level the player walked off (seams only).
* @returns the index entry, or null when the pack has no such level.
*/
function variantFor(levelId: number): IndexEntry | null {
const candidates = index.levels.filter(entry => entry.levelId === levelId)
if (candidates.length === 0) return null
return candidates[Math.abs(WORLD_VARIANT_SEED + levelId) % candidates.length] ?? null
function variantFor(levelId: number, fromSide?: Side): IndexEntry | null {
return variants.entryForLevel(levelId, fromSide)
}
/** Every way out of a level, with the radius that fires it. */
@ -278,7 +295,7 @@ const ROUTE: readonly Hop[] = [
console.log('=== 验收路线 ===')
let entry = variantFor(1)
let entry = variants.startEntry(1)
check(entry !== null, '资源包里有罗格营地(关卡 1)')
let scene = entry === null ? null : await sceneOf(entry)
let here = scene === null ? { x: 0, y: 0 } : spawnSubTile(scene) ?? { x: 0, y: 0 }
@ -312,18 +329,15 @@ for (const hop of ROUTE) {
+ `(连通区域 ${String(reach)} 子格)`,
)
const nextEntry = variantFor(hop.to)
const nextEntry = variantFor(hop.to, link.side)
check(nextEntry !== null, `${hop.note}:资源包里有目的地 ${String(hop.to)}`)
if (nextEntry === null) break
const nextScene = await sceneOf(nextEntry)
const back = linksOf(nextScene).find(candidate => candidate.toLevelId === fromLevelId)
check(back !== undefined, `${hop.note}:${nextScene.levelName} 有回到 ${String(fromLevelId)} 的对侧开口`)
const landing = back === undefined
? {
x: Math.floor(nextScene.cellsX * 5 / 2),
y: Math.floor(nextScene.cellsY * 5 / 2),
}
: { x: back.arriveX, y: back.arriveY }
// travel() refuses a destination with no way back, so the walk ends here too.
if (back === undefined) break
const landing = { x: back.arriveX, y: back.arriveY }
const nextBlocked = blockedOf(nextScene)
const inBounds = landing.x >= 0 && landing.y >= 0
&& landing.x < nextScene.collision.width && landing.y < nextScene.collision.height

190
src/game/act-variants.ts Normal file
View File

@ -0,0 +1,190 @@
/**
* Which baked copy of a level a world instance uses.
*
* Every generated level is baked more than once, and the world graph names none of the copies, so one
* has to be chosen at load time. Two rules decide it:
*
* - **Acts generated by the DRLG port choose per act, not per level.** D2 generates an act from one game
* seed: the outdoor levels, their seams and the town's gate all come out of the same run
* (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections -> DRLG_InitLevel). The bake does the same
* once per variant and records it in `index.json` as `actLayouts`: the generated levels (whose index
* entries carry `actVariant`) and the preset levels whose DS1 that run fixed (the Rogue Encampment
* file whose gate faces the Blood Moor). Mixing levels of two variants would join seams that were
* never generated together, so one variant is chosen per act and every level of the act follows it.
* - **Everything else chooses per level** (`WORLD_VARIANT_SEED + levelId`), preferring a copy whose gate
* faces the side the player arrives from (`findMatchingVariant`).
*
* The same code runs in the browser (`act-scene.ts`) and in `scripts/verify-world-walk.ts`, so the
* verifier walks exactly the world the player gets.
*
* Browser-safe: no `node:` imports, no `Math.random`.
*/
import { townLevelForAct } from './acts.ts'
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side, type VariantCandidate } from './variants.ts'
/**
* Seed of the per-world choices.
*
* A copy must never be chosen from the pair of levels being crossed: keying it on the pair, as an
* earlier version did, gave Cold Plains a different layout depending on whether you walked in from the
* Blood Moor or back from Stony Field, and left waypoints and town portals pointing at coordinates from a
* map that was no longer loaded. One copy per level (per act for DRLG acts) per world is the design.
*
* A real game would derive this from the game seed; until there is one, a constant keeps every session
* identical, which is also what the verification scripts assume.
*/
export const WORLD_VARIANT_SEED = 0x5eed_3000
/** One DRLG-generated copy of an act, as the bake records it (`index.json` `actLayouts[act]`). */
export interface ActLayout {
/** Variant number (0-based); the generated levels' index entries carry it as `actVariant`. */
readonly variant: number
/** The game seed the DRLG port generated this copy of the act from. */
readonly gameSeed: number
/** Levels generated by the DRLG port in this copy (one index entry each, with this `actVariant`). */
readonly levels: readonly number[]
/** Preset levels whose DS1 file the generated act fixed, e.g. the town whose gate faces the Blood Moor. */
readonly presets: readonly { readonly levelId: number; readonly ds1: string }[]
}
/** The index fields the selection reads. */
export interface VariantIndex<E extends VariantEntry> {
readonly levels: readonly E[]
/** DRLG act layouts keyed by act number (`'1'`..`'5'`); absent for acts the DRLG port does not generate. */
readonly actLayouts?: Readonly<Record<string, readonly ActLayout[]>> | undefined
}
/** The index entry fields the selection reads. */
export interface VariantEntry extends VariantCandidate {
readonly act: number
readonly levelId?: number
readonly label: string
/** DS1 member of a preset copy (as written by the bake). */
readonly ds1?: string
/** Variant of a DRLG-generated level (matches {@link ActLayout.variant}). */
readonly actVariant?: number
}
/** Archive paths compare case-insensitively and with either slash. */
function normalizeMember(path: string): string {
return path.replace(/\//g, '\\').toLowerCase()
}
/**
* The per-session choice of level copies.
*
* One instance per loaded pack. {@link pin} lets an explicitly opened copy (a `?level=` label, the
* variant menu) decide its act's variant, so walking on from there stays in the same generated act.
*/
export class WorldVariants<E extends VariantEntry> {
private readonly pinned = new Map<number, number>()
constructor(
private readonly index: VariantIndex<E>,
private readonly seed: number = WORLD_VARIANT_SEED,
) {
for (const [act, layouts] of Object.entries(index.actLayouts ?? {})) {
if (layouts.length === 0) throw new Error(`actLayouts[${act}] is empty`)
layouts.forEach((layout, at) => {
if (layout.variant !== at) throw new Error(`actLayouts[${act}][${String(at)}] has variant ${String(layout.variant)}`)
})
}
}
/** The act's DRLG layouts, or null when the act is not DRLG-generated. */
layoutsOf(act: number): readonly ActLayout[] | null {
return this.index.actLayouts?.[String(act)] ?? null
}
/**
* The copy of the act this world uses, or null when the act is not DRLG-generated.
*
* @param act - act number (1..5).
*/
layoutFor(act: number): ActLayout | null {
const layouts = this.layoutsOf(act)
if (layouts === null) return null
const variant = this.pinned.get(act) ?? Math.abs(this.seed + act) % layouts.length
const layout = layouts[variant]
if (layout === undefined) throw new Error(`act ${String(act)} has no layout variant ${String(variant)}`)
return layout
}
/**
* Make an explicitly opened copy decide its act's variant.
*
* A generated level pins its own `actVariant`; a preset copy pins the first variant whose act fixed
* that DS1 (a town file no variant uses pins nothing).
*
* @param entry - the copy being opened.
*/
pin(entry: E): void {
const layouts = this.layoutsOf(entry.act)
if (layouts === null) return
if (entry.actVariant !== undefined) {
if (layouts[entry.actVariant] === undefined) {
throw new Error(`${entry.label}: act ${String(entry.act)} has no layout variant ${String(entry.actVariant)}`)
}
this.pinned.set(entry.act, entry.actVariant)
return
}
if (entry.levelId === undefined || entry.ds1 === undefined) return
const ds1 = normalizeMember(entry.ds1)
const match = layouts.find(layout =>
layout.presets.some(p => p.levelId === entry.levelId && normalizeMember(p.ds1) === ds1))
if (match !== undefined) this.pinned.set(entry.act, match.variant)
}
/**
* The copy of a level this world uses.
*
* @param levelId - `Levels.txt` `Id`.
* @param fromSide - the edge of the previous level the player walked off, for per-level choices.
* @returns the index entry, or null when the pack has no copy of the level.
*/
entryForLevel(levelId: number, fromSide?: Side): E | null {
const candidates = this.index.levels.filter(entry => entry.levelId === levelId)
if (candidates.length === 0) return null
const act = candidates[0]!.act
if (candidates.some(entry => entry.act !== act)) throw new Error(`level ${String(levelId)} is baked into more than one act`)
const layout = this.layoutFor(act)
if (layout !== null) {
if (layout.levels.includes(levelId)) {
return exactlyOne(candidates.filter(entry => entry.actVariant === layout.variant),
`level ${String(levelId)}, act ${String(act)} variant ${String(layout.variant)}`)
}
const preset = layout.presets.find(p => p.levelId === levelId)
if (preset !== undefined) {
const ds1 = normalizeMember(preset.ds1)
return exactlyOne(candidates.filter(entry => entry.ds1 !== undefined && normalizeMember(entry.ds1) === ds1),
`level ${String(levelId)} DS1 ${preset.ds1} (act ${String(act)} variant ${String(layout.variant)})`)
}
}
const requiredSide = fromSide === undefined ? undefined : oppositeSide(fromSide)
return findMatchingVariant(candidates, requiredSide, this.seed + levelId) ?? null
}
/**
* The copy a fresh session opens an act in: the act's town.
*
* In a DRLG-generated act that is the town file the chosen copy of the act fixed (its gate faces
* the generated level next to it); other acts keep their fixed default (`getDefaultTownVariant`).
*
* @param act - act number (1..5).
* @returns the index entry, or null when the pack has nothing of this act.
*/
startEntry(act: number): E | null {
const forAct = this.index.levels.filter(entry => entry.act === act)
if (forAct.length === 0) return null
if (this.layoutFor(act) === null) return getDefaultTownVariant(forAct, act) ?? null
const town = this.entryForLevel(townLevelForAct(act))
if (town === null) throw new Error(`act ${String(act)} is DRLG-generated but the pack has no copy of its town`)
return town
}
}
function exactlyOne<E extends VariantEntry>(found: readonly E[], what: string): E {
if (found.length !== 1) throw new Error(`${what}: ${String(found.length)} baked copies match, expected exactly 1`)
return found[0]!
}

View File

@ -465,6 +465,18 @@ function resolveCandidates(
}
}
/**
* Whether a cell's DT1 mask lets the renderer draw a tile of `library` (libraries from index 21 on are
* never filtered).
*
* @param dt1Mask - the cell's `dt1Mask`.
* @param library - index into the scene's library list.
* @returns true when the library's tiles are candidates for the cell.
*/
export function dt1MaskAllows(dt1Mask: number, library: number): boolean {
return library >= 21 || ((dt1Mask >>> 0) & (1 << library)) !== 0
}
function filterByDt1Mask(
candidates: readonly PoolTile[] | undefined,
dt1Mask: number | undefined,
@ -472,8 +484,7 @@ function filterByDt1Mask(
if (candidates === undefined || candidates.length === 0 || dt1Mask === undefined) {
return candidates
}
const mask = dt1Mask >>> 0
const filtered = candidates.filter(c => c.library >= 21 || (mask & (1 << c.library)) !== 0)
const filtered = candidates.filter(c => dt1MaskAllows(dt1Mask, c.library))
return filtered.length > 0 ? filtered : candidates
}

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,10 @@ export interface D2TileLibraryEntryStrc {
nSequence: number
nRarity_Frame: number
nSubtileFlags: Uint8Array
/** Port identity (not a D2CMP field): normalized DT1 path of the owning library (= D2TileLibraryStrc.szPath). */
readonly szLibrary: string
/** Port identity (not a D2CMP field): index of this tile header inside its DT1 file. */
readonly nIndex: number
}
export interface D2TileLibraryStrc {
@ -62,6 +66,8 @@ function parseDt1Library(szPath: string, bytes: Uint8Array): D2TileLibraryStrc {
nSequence: view.getInt32(nBase + 0x1c, true),
nRarity_Frame: view.getInt32(nBase + 0x20, true),
nSubtileFlags,
szLibrary: szPath,
nIndex: i,
})
}
return { szPath, tiles }

View File

@ -1,14 +1,16 @@
/**
* JSON dumps of the DRLG port in the exact schema the native D2MOO oracle writes
* (tools/d2moo-oracle/src/main.cpp, schema 2; documented in tools/d2moo-oracle/README.md), plus the
* TypeScript twin of the oracle driver. Diffing the two dumps stage by stage is the acceptance gate
* of the port (scripts/drlg-diff.ts, tests/drlg-act1-oracle.test.ts).
* (tools/d2moo-oracle/src/main.cpp, schema 3; documented in tools/d2moo-oracle/README.md), plus the
* TypeScript twin of the oracle driver (`runAct1`, the single generation code path; `dumpAct1` builds the
* dump from its hooks). Diffing the two dumps stage by stage is the acceptance gate of the port
* (scripts/drlg-diff.ts, tests/drlg-act1-oracle.test.ts).
*
* Integer signedness follows the C field types, independent of how the port stores the value:
* uint32 fields are printed with `>>> 0`, int32 fields with `| 0`.
*/
import { setD2SeedTraceSink, type D2SeedStrc, type D2SeedTraceSink } from '../d2-rng.ts'
import type { D2TileLibraryEntryStrc } from './drlg-d2cmp.ts'
import {
ACT_I,
DRLGROOMFLAG_HAS_ROOM,
@ -22,18 +24,22 @@ import {
type D2DrlgCoordStrc,
type D2DrlgGridStrc,
type D2DrlgLevelStrc,
type D2DrlgLogicalRoomInfoStrc,
type D2DrlgMapStrc,
type D2DrlgOrthStrc,
type D2DrlgRoomStrc,
type D2DrlgStrc,
type D2DrlgTileDataStrc,
type D2DrlgVertexStrc,
type D2PresetUnitStrc,
type D2RoomCoordListStrc,
type DrlgEnv,
} from './drlg-types.ts'
import { DRLG_AllocDrlg, DRLG_GetActNoFromLevelId, DRLG_GetLevel, DRLG_InitLevel } from './drlg-drlg.ts'
import { setLevelGridHook } from './drlg-hooks.ts'
import { DRLGPRESET_SpawnHardcodedPresetUnits } from './drlg-preset.ts'
import { sub_6FD77BB0 } from './drlg-room.ts'
import { DRLGROOMTILE_InitRoomGrids, DRLGROOMTILE_LoadDT1FilesForRoom } from './drlg-roomtile.ts'
import { DRLGROOMTILE_AddRoomMapTiles, DRLGROOMTILE_InitRoomGrids, DRLGROOMTILE_LoadDT1FilesForRoom } from './drlg-roomtile.ts'
import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts'
const u32 = (v: number): number => v >>> 0
@ -86,6 +92,103 @@ export interface DumpRoom {
preset?: { prest: number; picked: number; flags: number; map: number }
}
// ---- activation stage (schema 3: + seedAfterTiles, mapTiles) ----
/**
* `D2DrlgTileDataStrc*`: [levelId, room index in pFirstRoomEx order, array, element index] into that room's
* `pTileGrid.pTiles` array: 'w' pWallTiles, 'f' pFloorTiles, 'r' pRoofTiles. The index may exceed the
* dumped element count (it addresses the allocation). Rooms of any level of the act can be referenced.
*/
export type DumpTileRef = [number, number, 'w' | 'f' | 'r', number]
/** `D2RoomCoordListStrc*`: [levelId, room, 'c', index in that room's `pLogicalRoomInfo.pCoordList` chain]. */
export type DumpCoordListRef = [number, number, 'c', number]
/** `D2TileLibraryEntryStrc*`: [DT1 path (lower case, '/'-separated, e.g. "data/global/tiles/act1/outdoors/blank.dt1"), tile index in that DT1]. */
export type DumpTileEntry = [string, number]
/** Every D2DrlgTileDataStrc field in struct order (D2DrlgDrlg.h:333); pointers as references. */
export type DumpTile = [
nWidth: number,
nHeight: number,
nPosX: number,
nPosY: number,
unk0x10: number | DumpCoordListRef,
dwFlags: number,
pTile: DumpTileEntry | null,
nTileType: number,
unk0x20: DumpTileRef | null,
unk0x24: number,
nRed: number,
nGreen: number,
nBlue: number,
nIntensity: number,
unk0x2C: number,
]
/** D2DrlgTileLinkStrc (pTileGrid.pMapLinks chain order). */
export type DumpTileLink = [bFloor: number, pMapTile: DumpTileRef | null]
/** D2RoomTileStrc (pRoomTiles chain order); the LvlWarp.txt record as its row index, -1 for NULL. */
export type DumpRoomTile = [
pDrlgRoom: [levelId: number, room: number] | null,
nLvlWarpTxtRecord: number,
bEnabled: number,
unk0x0C: DumpTileRef | null,
unk0x10: DumpTileRef | null,
]
/** D2RoomCoordListStrc. */
export type DumpCoordList = [nIndex: number, bNode: number, bRoomActive: number, box0: DumpCoord, box1: DumpCoord]
/** D2DrlgLogicalRoomInfoStrc. */
export interface DumpLogicalRoomInfo {
flags: number
nLists: number
indexX: DumpGrid | null
/** pIndexY cells (`(int)D2RoomCoordListStrc*`) as 1 + the index of the list in `lists`, 0 for NULL. */
indexY: DumpGrid | null
/** The pCoordList chain in order. */
lists: DumpCoordList[]
}
/**
* The room's D2DrlgTileGridStrc after every requested room has been activated (final state: a room
* activated later rewrites tiles of the near rooms activated before it, across level seams too).
*/
export interface DumpMapTiles {
/** pTileGrid counters. */
nWalls: number
nFloors: number
nShadows: number
/** pTileGrid.pTiles counters (what D2Common hands on with the room); walls/floors/roofs hold exactly these many entries. */
pTiles: { nWalls: number; nFloors: number; nRoofs: number }
walls: DumpTile[]
floors: DumpTile[]
/** Shadow tiles (DRLGROOMTILE_InitShadowTileData stores them in pRoofTiles). */
roofs: DumpTile[]
links: DumpTileLink[]
warps: DumpRoomTile[]
/** pPresetUnits in the final state (incl. the UNIT_TILE units of DRLGROOMTILE_AddWarp and door units). */
units: DumpUnit[]
logical: DumpLogicalRoomInfo | null
}
interface DumpActivationTail {
/** Room seed right after DRLGROOMTILE_InitRoomGrids. */
seed: DumpSeed
/** pPresetUnits right after DRLGROOMTILE_InitRoomGrids. */
units: DumpUnit[]
/** Room seed right after the room's own DRLGROOMTILE_AddRoomMapTiles. */
seedAfterTiles: DumpSeed
mapTiles: DumpMapTiles
}
export interface DumpOutdoorActivation extends DumpActivationTail {
tile: DumpGrid | null
wall: DumpGrid | null
floor: DumpGrid | null
dirt: DumpGrid | null
}
export interface DumpPresetActivation extends DumpActivationTail {
walls: (DumpGrid | null)[]
tiles: (DumpGrid | null)[]
floors: (DumpGrid | null)[]
shadow: DumpGrid | null
}
export type DumpActivation = DumpOutdoorActivation | DumpPresetActivation
export interface DumpLevel {
id: number
levelGrid: DumpLevelGrid
@ -94,11 +197,12 @@ export interface DumpLevel {
maps: DumpMap[]
levelSeedAfterRooms: DumpSeed
warp: { n: number; xy: [number, number][] }
activation: unknown[]
/** One entry per room in pFirstRoomEx order (empty when activation is skipped). */
activation: DumpActivation[]
}
export interface DrlgDump {
schema: 2
schema: 3
oracle?: { d2moo: string; arch: string }
gameSeed: number
difficulty: number
@ -318,22 +422,219 @@ export class DrlgTraceWriter implements D2SeedTraceSink {
}
}
// ---- mapTiles (schema 3) ----
/** Pointer -> reference tables over every room of one act (pDrlg->pLevel chain, pFirstRoomEx order). */
interface DumpRefIndex {
readonly tiles: Map<D2DrlgTileDataStrc, DumpTileRef>
readonly coordLists: Map<D2RoomCoordListStrc, DumpCoordListRef>
readonly rooms: Map<D2DrlgRoomStrc, [number, number]>
}
/** Chains longer than this cannot come from D2Common (C would loop forever on a cycle). */
const MAX_DUMP_CHAIN = 1 << 20
function buildRefIndex(pDrlg: D2DrlgStrc): DumpRefIndex {
const index: DumpRefIndex = { tiles: new Map(), coordLists: new Map(), rooms: new Map() }
for (let pLevel = pDrlg.pLevel; pLevel; pLevel = pLevel.pNextLevel) {
const nLevelId = pLevel.nLevelId
let nRoom = 0
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, nRoom += 1) {
index.rooms.set(pRoom, [nLevelId, nRoom])
const pTileGrid = pRoom.pTileGrid
if (pTileGrid) {
const add = (pArray: D2DrlgTileDataStrc[] | null, cArray: 'w' | 'f' | 'r'): void => {
if (!pArray) return
for (let i = 0; i < pArray.length; i += 1) {
const pTileData = pArray[i]!
if (index.tiles.has(pTileData)) throw new Error(`level ${nLevelId} room ${nRoom}: a tile data element belongs to two arrays`)
index.tiles.set(pTileData, [nLevelId, nRoom, cArray, i])
}
}
add(pTileGrid.pTiles.pWallTiles, 'w')
add(pTileGrid.pTiles.pFloorTiles, 'f')
add(pTileGrid.pTiles.pRoofTiles, 'r')
}
const pInfo = pRoom.pLogicalRoomInfo
if (pInfo) {
let k = 0
for (let p = pInfo.pCoordList; p; p = p.pNext, k += 1) {
if (k >= MAX_DUMP_CHAIN) throw new Error(`level ${nLevelId} room ${nRoom}: pCoordList chain does not terminate`)
index.coordLists.set(p, [nLevelId, nRoom, 'c', k])
}
}
}
}
return index
}
function dumpTileRef(index: DumpRefIndex, p: D2DrlgTileDataStrc | null): DumpTileRef | null {
if (!p) return null
const ref = index.tiles.get(p)
if (!ref) throw new Error('tile data pointer outside every tile array of the act (dangling?)')
return ref
}
function dumpTileEntry(pTile: D2TileLibraryEntryStrc | null): DumpTileEntry | null {
return pTile ? [pTile.szLibrary.replace(/\\/g, '/'), pTile.nIndex] : null
}
function dumpTileData(index: DumpRefIndex, t: D2DrlgTileDataStrc): DumpTile {
let unk0x10: number | DumpCoordListRef
if (typeof t.unk0x10 === 'number') {
unk0x10 = s32(t.unk0x10)
} else {
const ref = index.coordLists.get(t.unk0x10)
if (!ref) throw new Error('D2DrlgTileDataStrc::unk0x10 holds a coord list outside every pCoordList chain of the act')
unk0x10 = ref
}
return [
s32(t.nWidth),
s32(t.nHeight),
s32(t.nPosX),
s32(t.nPosY),
unk0x10,
u32(t.dwFlags),
dumpTileEntry(t.pTile),
s32(t.nTileType),
dumpTileRef(index, t.unk0x20),
s32(t.unk0x24),
u8(t.nRed),
u8(t.nGreen),
u8(t.nBlue),
u8(t.nIntensity),
s32(t.unk0x2C),
]
}
function dumpTileDataArray(index: DumpRefIndex, pArray: D2DrlgTileDataStrc[] | null, nTiles: number, szWhat: string): DumpTile[] {
if (nTiles < 0 || (nTiles > 0 && !pArray)) throw new Error(`${nTiles} ${szWhat} without an array`)
if (pArray && nTiles > pArray.length) throw new Error(`${nTiles} ${szWhat} exceed their ${pArray.length}-element allocation`)
const out: DumpTile[] = []
for (let i = 0; i < nTiles; i += 1) out.push(dumpTileData(index, pArray![i]!))
return out
}
function dumpLogicalRoomInfo(pInfo: D2DrlgLogicalRoomInfoStrc | null): DumpLogicalRoomInfo | null {
if (!pInfo) return null
const chain = new Map<D2RoomCoordListStrc, number>()
const lists: DumpCoordList[] = []
for (let p = pInfo.pCoordList; p; p = p.pNext) {
if (lists.length >= MAX_DUMP_CHAIN) throw new Error('pCoordList chain does not terminate')
chain.set(p, lists.length)
lists.push([s32(p.nIndex), s32(p.bNode), s32(p.bRoomActive), dumpCoord(p.pBox[0]), dumpCoord(p.pBox[1])])
}
// pIndexY cells hold `(int)D2RoomCoordListStrc*` in C and a 1-based handle into ppCoordListHandles here.
let indexY: DumpGrid | null = null
const g = pInfo.pIndexY
if (g.pCellsFlags && g.pCellsRowOffsets) {
const cells: number[] = []
for (let y = 0; y < g.nHeight; y += 1) {
for (let x = 0; x < g.nWidth; x += 1) {
const idx = g.nCellsBase + x + g.pCellsRowOffsets[y]!
if (idx < 0 || idx >= g.pCellsFlags.length) throw new RangeError(`pIndexY cell (${x}, ${y}) outside the backing array`)
const hCell = g.pCellsFlags[idx]!
if (hCell === 0) {
cells.push(0)
continue
}
const pList = hCell > 0 ? pInfo.ppCoordListHandles[hCell - 1] : undefined
const k = pList ? chain.get(pList) : undefined
if (k === undefined) throw new Error(`pIndexY cell (${x}, ${y}) is not a list of the room's chain`)
cells.push(k + 1)
}
}
indexY = { w: s32(g.nWidth), h: s32(g.nHeight), cells }
}
return { flags: s32(pInfo.dwFlags), nLists: s32(pInfo.nLists), indexX: dumpGrid(pInfo.pIndexX), indexY, lists }
}
function dumpRoomTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc): DumpRoomTile[] {
const lvlWarp = pRoom.pLevel.pDrlg.env.tables.lvlWarp
const out: DumpRoomTile[] = []
for (let p = pRoom.pRoomTiles; p; p = p.pNext) {
if (out.length >= MAX_DUMP_CHAIN) throw new Error('pRoomTiles chain does not terminate')
let room: [number, number] | null = null
if (p.pDrlgRoom) {
const r = index.rooms.get(p.pDrlgRoom)
if (!r) throw new Error(`warp ${out.length} leads to a room outside the act`)
room = [r[0], r[1]]
}
let nWarp = -1
if (p.pLvlWarpTxtRecord) {
nWarp = lvlWarp.indexOf(p.pLvlWarpTxtRecord)
if (nWarp < 0) throw new Error(`warp ${out.length} has a record outside LvlWarp.txt`)
}
out.push([room, nWarp, s32(p.bEnabled), dumpTileRef(index, p.unk0x0C), dumpTileRef(index, p.unk0x10)])
}
return out
}
function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: number, nRoom: number): DumpMapTiles {
const where = `level ${nLevelId} room ${nRoom}`
const pTileGrid = pRoom.pTileGrid
if (!pTileGrid) throw new Error(`${where}: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles`)
if (pTileGrid.pAnimTiles) throw new Error(`${where}: animated tiles (pAnimTiles) are not dumped`)
try {
const t = pTileGrid.pTiles
const links: DumpTileLink[] = []
for (let p = pTileGrid.pMapLinks; p; p = p.pNext) {
if (links.length >= MAX_DUMP_CHAIN) throw new Error('pMapLinks chain does not terminate')
links.push([s32(p.bFloor), dumpTileRef(index, p.pMapTile)])
}
return {
nWalls: s32(pTileGrid.nWalls),
nFloors: s32(pTileGrid.nFloors),
nShadows: s32(pTileGrid.nShadows),
pTiles: { nWalls: s32(t.nWalls), nFloors: s32(t.nFloors), nRoofs: s32(t.nRoofs) },
walls: dumpTileDataArray(index, t.pWallTiles, t.nWalls, 'walls'),
floors: dumpTileDataArray(index, t.pFloorTiles, t.nFloors, 'floors'),
roofs: dumpTileDataArray(index, t.pRoofTiles, t.nRoofs, 'roofs'),
links,
warps: dumpRoomTiles(index, pRoom),
units: dumpPresetUnits(pRoom.pPresetUnits),
logical: dumpLogicalRoomInfo(pRoom.pLogicalRoomInfo),
}
} catch (e) {
throw new Error(`${where}: ${e instanceof Error ? e.message : String(e)}`)
}
}
// ---- driver (TypeScript twin of tools/d2moo-oracle/src/main.cpp) ----
export interface DumpAct1Options {
export interface Act1RunOptions {
readonly difficulty?: number
/** A fresh act per requested level (proves a level does not depend on the others). */
readonly isolated?: boolean
/** Receives the RNG trace; installed as the global seed trace sink for the duration of the run. */
readonly trace?: DrlgTraceWriter
/** Stop after the act stage (the `levels` array stays empty). */
readonly actOnly?: boolean
/**
* Generate the levels but do not activate their rooms: every `activation` array stays empty.
* Only valid for comparisons that exclude the activation stage (drlg-diff --no-activation).
*/
/** Generate the levels but do not activate their rooms. */
readonly skipActivation?: boolean
}
/**
* Observation points of runAct1, in execution order. The structures are live: later steps keep mutating
* them (a room activated later rewrites tiles of the near rooms activated before it and rolls their
* seeds, also across level seams), so a hook must copy what it needs at that moment.
*/
export interface Act1RunHooks {
/** After DRLG_AllocDrlg. `index` 0 is Act1Run.pDrlg; isolated runs allocate one more act per further level. */
onDrlgAllocated?(pDrlg: D2DrlgStrc, index: number): void
/** After DRLG_InitLevel of a requested level. */
onLevelGenerated?(pLevel: D2DrlgLevelStrc): void
/** After DRLGROOMTILE_InitRoomGrids of a room, before DRLGROOMTILE_AddRoomMapTiles (`index`: pFirstRoomEx order). */
onRoomGridsInitialized?(pRoom: D2DrlgRoomStrc, index: number): void
/** After DRLGROOMTILE_AddRoomMapTiles of that room. */
onRoomActivated?(pRoom: D2DrlgRoomStrc, index: number): void
}
export interface Act1Run {
/** The act allocated first (the only one unless `isolated`). */
readonly pDrlg: D2DrlgStrc
/** The requested levels in request order; each level's own act is `pLevel.pDrlg`. */
readonly levels: readonly D2DrlgLevelStrc[]
}
function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc {
trace?.context('drlg')
// D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act
@ -341,7 +642,8 @@ function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: Drlg
return DRLG_AllocDrlg(env, ACT_I, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nDifficulty)
}
function initLevelStage(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined, grids: Map<number, DumpLevelGrid>): { head: Omit<DumpLevel, 'activation'>; pLevel: D2DrlgLevelStrc } {
/** Generation stage of one level: DRLG_InitLevel, everything that exists before any room is activated. */
function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc {
trace?.context(`L${nLevelId}`)
const pLevel = DRLG_GetLevel(pDrlg, nLevelId)
if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== ACT_I) {
@ -349,77 +651,16 @@ function initLevelStage(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWri
}
if (pLevel.pFirstRoomEx) throw new Error(`level ${nLevelId} was already generated before its own generation stage`)
DRLG_InitLevel(pLevel)
const levelGrid = grids.get(nLevelId)
if (!levelGrid) throw new Error(`level ${nLevelId}: InitAct1OutdoorLevel hook did not fire`)
const maps: D2DrlgMapStrc[] = []
const rooms = dumpRooms(pLevel, maps)
const xy: [number, number][] = []
for (let i = 0; i < pLevel.nRoomCoords; i += 1) xy.push([pLevel.nRoom_Center_Warp_X[i]!, pLevel.nRoom_Center_Warp_Y[i]!])
return {
head: {
id: nLevelId,
levelGrid,
nRooms: pLevel.nRooms,
rooms,
maps: maps.map(m => dumpMap(pDrlg.env, m)),
levelSeedAfterRooms: dumpSeed(pLevel.pSeed),
warp: { n: pLevel.nRoomCoords, xy },
},
pLevel,
}
return pLevel
}
/** Runs the Act I generation exactly like the oracle driver and returns the same JSON document. */
export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], opts: DumpAct1Options = {}): DrlgDump {
const nDifficulty = opts.difficulty ?? 0
const isolated = opts.isolated ?? false
const trace = opts.trace
const grids = new Map<number, DumpLevelGrid>()
if (trace) setD2SeedTraceSink(trace)
const previousHook = setLevelGridHook(pLevel => {
grids.set(pLevel.nLevelId, dumpLevelGrid(pLevel))
})
try {
let pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
const doc: DrlgDump = {
schema: 2,
gameSeed: nSeed >>> 0,
difficulty: nDifficulty,
isolated,
drlg: { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) },
act: dumpAct(pDrlg),
levels: [],
}
if (opts.actOnly) return doc
const heads: Omit<DumpLevel, 'activation'>[] = []
const generated: D2DrlgLevelStrc[] = []
const activations: unknown[][] = []
if (isolated) {
for (let i = 0; i < levels.length; i += 1) {
if (i > 0) pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
const r = initLevelStage(pDrlg, levels[i]!, trace, grids)
heads.push(r.head)
generated.push(r.pLevel)
activations.push(opts.skipActivation ? [] : activateLevel(r.pLevel, trace))
}
} else {
for (let i = 0; i < levels.length; i += 1) {
const r = initLevelStage(pDrlg, levels[i]!, trace, grids)
heads.push(r.head)
generated.push(r.pLevel)
}
for (let i = 0; i < levels.length; i += 1) activations.push(opts.skipActivation ? [] : activateLevel(generated[i]!, trace))
}
for (let i = 0; i < levels.length; i += 1) doc.levels.push({ ...heads[i]!, activation: activations[i]! })
return doc
} finally {
setLevelGridHook(previousHook)
if (trace) setD2SeedTraceSink(null)
}
}
function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefined): unknown[] {
const out: unknown[] = []
/**
* Mirrors DRLGACTIVATE_InitializeRoomEx (DrlgActivate.cpp:317-331) followed by
* DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-115) up to DRLGROOMTILE_AddRoomMapTiles, for every
* room in pFirstRoomEx order. DRLG_CreateRoomForRoomEx (D2Game's D2ActiveRoomStrc) is not run, so pRoom
* stays NULL (D2Common_COLLISION_FirstFn_6FD41000 is a no-op).
*/
function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefined, hooks: Act1RunHooks): void {
let i = 0
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, i += 1) {
trace?.context(`L${pLevel.nLevelId}/a${i}`)
@ -429,25 +670,108 @@ function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefin
if (!(pRoom.dwFlags & DRLGROOMFLAG_PRESET_UNITS_ADDED) && pRoom.nType === DRLGTYPE_PRESET) {
DRLGPRESET_SpawnHardcodedPresetUnits(pRoom)
}
if (pRoom.dwFlags & DRLGROOMFLAG_HAS_ROOM) {
if (pRoom.pRoom || pRoom.dwFlags & DRLGROOMFLAG_HAS_ROOM) {
throw new Error(`level ${pLevel.nLevelId} room ${i}: already has an active room`)
}
if (!pRoom.nRoomsNear) {
sub_6FD77BB0(pRoom)
}
DRLGROOMTILE_InitRoomGrids(pRoom)
hooks.onRoomGridsInitialized?.(pRoom, i)
DRLGROOMTILE_AddRoomMapTiles(pRoom)
hooks.onRoomActivated?.(pRoom, i)
}
}
/**
* Runs the Act I generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then
* DRLG_InitLevel of every requested level, then the activation of every room of those levels (see
* activateLevel). A non-isolated run generates every requested level before activating any room, because
* activating a room initialises the levels behind its warps (sub_6FD77BB0 -> DRLG_InitLevel); an isolated
* run allocates a fresh act per level and activates it right away.
*/
export function runAct1(
env: DrlgEnv,
nSeed: number,
levels: readonly number[],
opts: Act1RunOptions = {},
hooks: Act1RunHooks = {},
): Act1Run {
const nDifficulty = opts.difficulty ?? 0
const trace = opts.trace
if (trace) setD2SeedTraceSink(trace)
try {
let nActs = 0
const allocAct = (): D2DrlgStrc => {
const pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
hooks.onDrlgAllocated?.(pDrlg, nActs)
nActs += 1
return pDrlg
}
const generate = (pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLevelStrc => {
const pLevel = initLevel(pDrlg, nLevelId, trace)
hooks.onLevelGenerated?.(pLevel)
return pLevel
}
const pFirstDrlg = allocAct()
const generated: D2DrlgLevelStrc[] = []
if (opts.isolated) {
for (let i = 0; i < levels.length; i += 1) {
const pLevel = generate(i > 0 ? allocAct() : pFirstDrlg, levels[i]!)
generated.push(pLevel)
if (!opts.skipActivation) activateLevel(pLevel, trace, hooks)
}
} else {
for (let i = 0; i < levels.length; i += 1) generated.push(generate(pFirstDrlg, levels[i]!))
if (!opts.skipActivation) {
for (const pLevel of generated) activateLevel(pLevel, trace, hooks)
}
}
return { pDrlg: pFirstDrlg, levels: generated }
} finally {
if (trace) setD2SeedTraceSink(null)
}
}
export interface DumpAct1Options extends Act1RunOptions {
/** Stop after the act stage (the `levels` array stays empty). */
readonly actOnly?: boolean
/**
* Generate the levels but do not activate their rooms: every `activation` array stays empty.
* Only valid for comparisons that exclude the activation stage (drlg-diff --no-activation).
*/
readonly skipActivation?: boolean
}
function dumpLevelHead(pLevel: D2DrlgLevelStrc, levelGrid: DumpLevelGrid): Omit<DumpLevel, 'activation'> {
const maps: D2DrlgMapStrc[] = []
const rooms = dumpRooms(pLevel, maps)
const xy: [number, number][] = []
for (let i = 0; i < pLevel.nRoomCoords; i += 1) xy.push([pLevel.nRoom_Center_Warp_X[i]!, pLevel.nRoom_Center_Warp_Y[i]!])
return {
id: pLevel.nLevelId,
levelGrid,
nRooms: pLevel.nRooms,
rooms,
maps: maps.map(m => dumpMap(pLevel.pDrlg.env, m)),
levelSeedAfterRooms: dumpSeed(pLevel.pSeed),
warp: { n: pLevel.nRoomCoords, xy },
}
}
/** Schema-2 activation fields of a room, right after DRLGROOMTILE_InitRoomGrids. */
function dumpRoomGrids(pRoom: D2DrlgRoomStrc): Omit<DumpOutdoorActivation, 'seedAfterTiles' | 'mapTiles'> | Omit<DumpPresetActivation, 'seedAfterTiles' | 'mapTiles'> {
if (pRoom.nType === DRLGTYPE_MAZE) {
const p = pRoom.pOutdoor!
out.push({
return {
seed: dumpSeed(pRoom.pSeed),
tile: dumpGrid(p.pTileTypeGrid),
wall: dumpGrid(p.pWallGrid),
floor: dumpGrid(p.pFloorGrid),
dirt: dumpGrid(p.pDirtPathGrid),
units: dumpPresetUnits(pRoom.pPresetUnits),
})
} else {
}
}
const p = pRoom.pMaze!
const pFile = p.pMap!.pFile!
const walls: (DumpGrid | null)[] = []
@ -460,16 +784,80 @@ function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefin
for (let l = 0; l < pFile.nFloorLayers; l += 1) {
floors.push(dumpGrid(p.pFloorGrid[l]!))
}
out.push({
seed: dumpSeed(pRoom.pSeed),
walls,
tiles,
floors,
shadow: dumpGrid(p.pCellGrid),
units: dumpPresetUnits(pRoom.pPresetUnits),
})
}
}
return out
return { seed: dumpSeed(pRoom.pSeed), walls, tiles, floors, shadow: dumpGrid(p.pCellGrid), units: dumpPresetUnits(pRoom.pPresetUnits) }
}
interface RoomActivationRecord {
readonly pRoom: D2DrlgRoomStrc
readonly index: number
readonly grids: ReturnType<typeof dumpRoomGrids>
seedAfterTiles: DumpSeed | null
}
/** Runs runAct1 and returns the same JSON document as the oracle driver. */
export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], opts: DumpAct1Options = {}): DrlgDump {
const levelGrids = new Map<number, DumpLevelGrid>()
const heads = new Map<D2DrlgLevelStrc, Omit<DumpLevel, 'activation'>>()
const activations = new Map<D2DrlgLevelStrc, RoomActivationRecord[]>()
// Assigned by the hooks (the casts keep TypeScript from narrowing them to `null` for good).
let drlg = null as DrlgDump['drlg'] | null
let act = null as DumpActLevel[] | null
const previousHook = setLevelGridHook(pLevel => {
levelGrids.set(pLevel.nLevelId, dumpLevelGrid(pLevel))
})
let run: Act1Run
try {
run = runAct1(env, nSeed, opts.actOnly ? [] : levels, opts, {
onDrlgAllocated(pDrlg, index) {
if (index !== 0) return
drlg = { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) }
act = dumpAct(pDrlg)
},
onLevelGenerated(pLevel) {
const levelGrid = levelGrids.get(pLevel.nLevelId)
if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: InitAct1OutdoorLevel hook did not fire`)
heads.set(pLevel, dumpLevelHead(pLevel, levelGrid))
activations.set(pLevel, [])
},
onRoomGridsInitialized(pRoom, index) {
const records = activations.get(pRoom.pLevel)
if (!records) throw new Error(`level ${pRoom.pLevel.nLevelId}: room ${index} activated before its level was generated`)
records.push({ pRoom, index, grids: dumpRoomGrids(pRoom), seedAfterTiles: null })
},
onRoomActivated(pRoom, index) {
const records = activations.get(pRoom.pLevel)
const record = records ? records[records.length - 1] : undefined
if (!record || record.pRoom !== pRoom || record.index !== index) throw new Error(`level ${pRoom.pLevel.nLevelId} room ${index}: activation hooks out of order`)
record.seedAfterTiles = dumpSeed(pRoom.pSeed)
},
})
} finally {
setLevelGridHook(previousHook)
}
if (!drlg || !act) throw new Error('runAct1 did not report the act allocation')
const doc: DrlgDump = {
schema: 3,
gameSeed: nSeed >>> 0,
difficulty: opts.difficulty ?? 0,
isolated: opts.isolated ?? false,
drlg,
act,
levels: [],
}
// mapTiles is serialised only now: the tile data are the final state (see DumpMapTiles).
const refIndices = new Map<D2DrlgStrc, DumpRefIndex>()
for (const pLevel of run.levels) {
let index = refIndices.get(pLevel.pDrlg)
if (!index) {
index = buildRefIndex(pLevel.pDrlg)
refIndices.set(pLevel.pDrlg, index)
}
const activation: DumpActivation[] = []
for (const record of activations.get(pLevel)!) {
if (!record.seedAfterTiles) throw new Error(`level ${pLevel.nLevelId} room ${record.index}: DRLGROOMTILE_AddRoomMapTiles did not complete`)
activation.push({ ...record.grids, seedAfterTiles: record.seedAfterTiles, mapTiles: dumpMapTiles(index, record.pRoom, pLevel.nLevelId, record.index) })
}
doc.levels.push({ ...heads.get(pLevel)!, activation })
}
return doc
}

542
src/game/drlg/drlg-logic.ts Normal file
View File

@ -0,0 +1,542 @@
/**
* D2Common DRLG logical rooms: 1:1 port of the parts of D2MOO source/D2Common/src/Drlg/DrlgDrlgLogic.cpp
* (commit 5596f5c, MIT License, Copyright (c) 2020-2025 The Phrozen Keep community) reached by
* DRLGROOMTILE_AddRoomMapTiles: DRLGOUTROOM_InitializeDrlgOutdoorRoom and DRLGPRESET_AddPresetRoomMapTiles
* end with DRLGLOGIC_AllocCoordLists or (LvlPrest.txt `Logicals`) DRLGLOGIC_InitializeDrlgCoordList.
*
* Pointers in int cells: DRLGLOGIC_AssignCoordListsForGrids writes `(int)pRoomCoordList` into the pIndexY
* cells and DRLGLOGIC_SetCoordListForTiles copies such a cell into D2DrlgTileDataStrc::unk0x10. Int32Array
* cells cannot hold objects, so a pIndexY cell holds a 1-based handle into
* `pLogicalRoomInfo.ppCoordListHandles` (0 = NULL), and the tile's unk0x10 receives the object itself.
*/
import { DRLGGRID_AlterGridFlag, DRLGGRID_FillGrid, DRLGGRID_GetGridEntry, DRLGGRID_InitializeGridCells, DRLGGRID_IsGridValid, DRLGGRID_ResetGrid } from './drlg-grid.ts'
import { DRLG_CheckNotOverlappingUsingManhattanDistance, DRLGROOM_AreXYInsideCoordinatesOrOnBorder } from './drlg-room.ts'
import {
GetMapTileLayer,
HasMapTileLayer,
MAPTILE_OBJECT_WALL,
MAPTILE_TREES,
PackedTile,
TILETYPE_ROOF,
tileAt,
tileGridOf,
} from './drlg-roomtile.ts'
import {
FLAG_OPERATION_OR,
FLAG_OPERATION_OVERWRITE,
newCoord,
newGrid,
type D2DrlgGridStrc,
type D2DrlgLogicalRoomInfoStrc,
type D2DrlgRoomStrc,
type D2DrlgTileDataStrc,
type D2RoomCoordListStrc,
} from './drlg-types.ts'
// D2DrlgLogicalRoomInfoFlags (D2DrlgDrlgLogic.h)
export const DRLGLOGIC_ROOMINFO_HAS_COORD_LIST = 0x1
export const DRLGLOGIC_ROOMINFO_HAS_GRID_CELLS = 0x2
/** D2UnkDrlgLogicStrc (D2DrlgDrlgLogic.h) */
interface D2UnkDrlgLogicStrc {
pDrlgRoom: D2DrlgRoomStrc
field_4: D2DrlgGridStrc
pTileTypeGrid: D2DrlgGridStrc
pWallGrid: D2DrlgGridStrc
pFloorGrid: D2DrlgGridStrc
field_14: D2DrlgGridStrc
field_18: number
nFlags: number
}
/** A zeroed D2RoomCoordListStrc (D2_CALLOC_STRC_POOL / an element of a D2_CALLOC_POOL block). */
function newRoomCoordList(): D2RoomCoordListStrc {
return { pBox: [newCoord(0, 0, 0, 0), newCoord(0, 0, 0, 0)], bNode: 0, bRoomActive: 0, nIndex: 0, pNext: null }
}
/** A zeroed D2DrlgLogicalRoomInfoStrc (D2_CALLOC_STRC_POOL). */
function newLogicalRoomInfo(): D2DrlgLogicalRoomInfoStrc {
return { dwFlags: 0, nLists: 0, pIndexX: newGrid(), pIndexY: newGrid(), pCoordList: null, ppCoordListHandles: [] }
}
/** `(D2RoomCoordListStrc*)nCell` for a pIndexY cell of pLogicalRoomInfo. */
function coordListFromCell(pLogicalRoomInfo: D2DrlgLogicalRoomInfoStrc, nCell: number): D2RoomCoordListStrc | null {
if (nCell === 0) return null
const pRoomCoordList = nCell > 0 ? pLogicalRoomInfo.ppCoordListHandles[nCell - 1] : undefined
if (!pRoomCoordList) throw new Error(`pIndexY cell ${nCell} is not a D2RoomCoordListStrc* (wild pointer)`)
return pRoomCoordList
}
/** C would loop forever on a cyclic tile chain; the port refuses instead. */
function chainStep(n: number): number {
if (n > 1 << 22) throw new Error('DRLGLOGIC: tile chain does not terminate (C would loop forever)')
return n + 1
}
//D2Common.0x6FD764A0
export function DRLGLOGIC_InitializeDrlgCoordList(
pDrlgRoom: D2DrlgRoomStrc,
pTileTypeGrid: D2DrlgGridStrc,
pFloorGrid: D2DrlgGridStrc,
pWallGrid: D2DrlgGridStrc,
): void {
const nCellPositions = new Int32Array(1024)
const nCellFlags = new Int32Array(256)
const pDrlgCoordList = newLogicalRoomInfo()
pDrlgRoom.pLogicalRoomInfo = pDrlgCoordList
let pDrlgRoomTiles = tileGridOf(pDrlgRoom).pTiles
for (let i = 0; i < pDrlgRoomTiles.nWalls; i += 1) {
const pWallTileData = tileAt(pDrlgRoomTiles.pWallTiles, i, 'DRLGLOGIC_InitializeDrlgCoordList')
if (pWallTileData.dwFlags & MAPTILE_TREES && !HasMapTileLayer(pWallTileData.dwFlags)) {
DRLGGRID_AlterGridFlag(pWallGrid, pWallTileData.nPosX, pWallTileData.nPosY, 8, FLAG_OPERATION_OR)
}
}
pDrlgCoordList.dwFlags |= DRLGLOGIC_ROOMINFO_HAS_GRID_CELLS
DRLGGRID_InitializeGridCells(pDrlgCoordList.pIndexX, (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0, (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0)
if (!pDrlgRoom.pLevel.nCoordLists) {
pDrlgRoom.pLevel.nCoordLists = 1
}
const nLists = pDrlgRoom.pLevel.nCoordLists
const pDrlgGrid = newGrid()
DRLGGRID_FillGrid(pDrlgGrid, (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0, (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0, nCellPositions, 0, nCellFlags)
pDrlgRoomTiles = tileGridOf(pDrlgRoom).pTiles
for (let i = 0; i < pDrlgRoomTiles.nWalls; i += 1) {
const pWallTileData = tileAt(pDrlgRoomTiles.pWallTiles, i, 'DRLGLOGIC_InitializeDrlgCoordList')
if (
GetMapTileLayer(pWallTileData.dwFlags) === 1 &&
pWallTileData.nTileType !== TILETYPE_ROOF &&
!(pWallTileData.dwFlags & MAPTILE_OBJECT_WALL)
) {
DRLGGRID_AlterGridFlag(pDrlgGrid, pWallTileData.nPosX, pWallTileData.nPosY, 1, FLAG_OPERATION_OR)
}
}
for (let i = 0; i < pDrlgRoom.nRoomsNear; i += 1) {
const pDrlgRoomNear = pDrlgRoom.ppRoomsNear[i]!
if (pDrlgRoomNear !== pDrlgRoom && pDrlgRoomNear.pTileGrid) {
let nLinks = 0
for (let pDrlgTileLink = pDrlgRoomNear.pTileGrid.pMapLinks; pDrlgTileLink; pDrlgTileLink = pDrlgTileLink.pNext) {
nLinks = chainStep(nLinks)
if (!pDrlgTileLink.bFloor) {
let nTiles = 0
for (let pDrlgTileData: D2DrlgTileDataStrc | null = pDrlgTileLink.pMapTile; pDrlgTileData; pDrlgTileData = pDrlgTileData.unk0x20) {
nTiles = chainStep(nTiles)
if (DRLG_CheckLayer1ButNotWallObject(pDrlgTileData)) {
const nX = (pDrlgRoomNear.pDrlgCoord.nPosX + pDrlgTileData.nPosX) | 0
const nY = (pDrlgRoomNear.pDrlgCoord.nPosY + pDrlgTileData.nPosY) | 0
if (DRLGROOM_AreXYInsideCoordinatesOrOnBorder(pDrlgRoom.pDrlgCoord, nX, nY)) {
DRLGGRID_AlterGridFlag(
pDrlgGrid,
(nX - pDrlgRoom.pDrlgCoord.nPosX) | 0,
(nY - pDrlgRoom.pDrlgCoord.nPosY) | 0,
1,
FLAG_OPERATION_OR,
)
}
}
}
}
}
}
}
const tDRLGLogicUnkStrc: D2UnkDrlgLogicStrc = {
pDrlgRoom,
field_4: pDrlgCoordList.pIndexX,
pTileTypeGrid,
pWallGrid,
pFloorGrid,
field_14: pDrlgGrid,
field_18: nLists,
nFlags: 0,
}
for (let j = 0; j <= pDrlgRoom.pDrlgCoord.nHeight; j += 1) {
for (let i = 0; i <= pDrlgRoom.pDrlgCoord.nWidth; i += 1) {
if (!(DRLGGRID_GetGridEntry(tDRLGLogicUnkStrc.field_4, i, j) & 0x10000000)) {
tDRLGLogicUnkStrc.field_18 = (tDRLGLogicUnkStrc.field_18 + 1) | 0
tDRLGLogicUnkStrc.nFlags = (tDRLGLogicUnkStrc.field_18 & 0xfffffff) | 0x10000000
const nFloorPackedInfo = DRLGGRID_GetGridEntry(tDRLGLogicUnkStrc.pFloorGrid, i, j) >>> 0
if (
(PackedTile.nTileStyle(nFloorPackedInfo) === 30 && PackedTile.nWallLayer(nFloorPackedInfo) === 0) ||
PackedTile.bHidden(nFloorPackedInfo)
) {
tDRLGLogicUnkStrc.nFlags |= 0x20000000
}
DRLGLOGIC_SetTileGridFlags(tDRLGLogicUnkStrc, i, j, -1)
}
}
}
DRLGGRID_ResetGrid(pDrlgGrid)
pDrlgCoordList.nLists = (tDRLGLogicUnkStrc.field_18 - nLists + 1) | 0
// D2_CALLOC_POOL(sizeof(D2RoomCoordListStrc) * nLists): only element [0] is ever reachable (it ends the
// chain built by DRLGLOGIC_AssignCoordListsForGrids), so only that element is modelled.
if (pDrlgCoordList.nLists <= 0) throw new RangeError(`DRLGLOGIC_InitializeDrlgCoordList: nLists = ${pDrlgCoordList.nLists}`)
pDrlgCoordList.pCoordList = newRoomCoordList()
pDrlgRoom.pLevel.nCoordLists = (pDrlgRoom.pLevel.nCoordLists + pDrlgCoordList.nLists) | 0
DRLGLOGIC_AssignCoordListsForGrids(pDrlgRoom, pDrlgCoordList, nLists)
DRLGLOGIC_SetCoordListForTiles(pDrlgRoom)
sub_6FD769B0(pDrlgRoom)
}
const stru_6FDCE5C8: readonly { nX: number; nY: number }[] = [
{ nX: 1, nY: 0 },
{ nX: 0, nY: 1 },
{ nX: -1, nY: 0 },
{ nX: 0, nY: -1 },
]
const dword_6FDCE5EC: readonly number[] = [
23, 0, 5, 21, 17, 15, 3, 0, 9, 7, 39, 0, 0, 5, 3, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 0,
]
const dword_6FDCE650: readonly number[] = [-1, 0, 1, 2, 2, 0, 1, 3, 0, 1, 0, 1, 4, -1, 4, 0, 0, 0, 0, 0]
//D2Common.0x6FD76830
function DRLGLOGIC_SetTileGridFlags(a1: D2UnkDrlgLogicStrc, nX: number, nY: number, a4: number): void {
let nPosX = nX
let nPosY = nY
while (
DRLGROOM_AreXYInsideCoordinatesOrOnBorder(
a1.pDrlgRoom.pDrlgCoord,
(nPosX + a1.pDrlgRoom.pDrlgCoord.nPosX) | 0,
(nPosY + a1.pDrlgRoom.pDrlgCoord.nPosY) | 0,
)
) {
if (DRLGGRID_GetGridEntry(a1.field_4, nPosX, nPosY) & 0x10000000) {
return
}
if (!DRLGGRID_GetGridEntry(a1.field_14, nPosX, nPosY)) {
DRLGGRID_AlterGridFlag(a1.field_4, nPosX, nPosY, a1.nFlags, FLAG_OPERATION_OR)
for (let i = 0; i < 4; i += 1) {
DRLGLOGIC_SetTileGridFlags(a1, (nPosX + stru_6FDCE5C8[i]!.nX) | 0, (nPosY + stru_6FDCE5C8[i]!.nY) | 0, i)
}
return
}
let nIndex = 0
if (DRLGGRID_IsGridValid(a1.pTileTypeGrid)) {
nIndex = DRLGGRID_GetGridEntry(a1.pTileTypeGrid, nPosX, nPosY)
}
// dword_6FDCE650 has 20 entries and dword_6FDCE5EC 26: any other subscript reads unrelated memory (UB;
// tile types 0 and 13 map to row -1, which lands before dword_6FDCE5EC).
const nRow = nIndex >= 0 && nIndex < dword_6FDCE650.length ? dword_6FDCE650[nIndex]! : undefined
const nFlagIndex = nRow === undefined ? -1 : (a4 + 5 * nRow + 1) | 0
if (nRow === undefined || nFlagIndex < 0 || nFlagIndex >= dword_6FDCE5EC.length) {
throw new RangeError(
`DRLGLOGIC_SetTileGridFlags: out-of-bounds table read (tile type ${nIndex}, a4 ${a4}) at room tile (${nPosX}, ${nPosY})`,
)
}
const nFlags = dword_6FDCE5EC[nFlagIndex]!
if (nFlags & 1) {
DRLGGRID_AlterGridFlag(a1.field_4, nPosX, nPosY, a1.nFlags, FLAG_OPERATION_OR)
}
if (nFlags & 2 && a4 !== 2) {
DRLGLOGIC_SetTileGridFlags(a1, (nPosX + 1) | 0, nPosY, 0)
}
if (nFlags & 4 && a4 !== 3) {
DRLGLOGIC_SetTileGridFlags(a1, nPosX, (nPosY + 1) | 0, 1)
}
if (nFlags & 8 && a4 !== 0) {
DRLGLOGIC_SetTileGridFlags(a1, (nPosX - 1) | 0, nPosY, 2)
}
if (nFlags & 16 && a4 !== 1) {
DRLGLOGIC_SetTileGridFlags(a1, nPosX, (nPosY - 1) | 0, 3)
}
if (!(nFlags & 32)) {
return
}
nPosY = (nPosY + 1) | 0
nPosX = (nPosX + 1) | 0
a4 = -1
}
}
//D2Common.0x6FD769B0
function sub_6FD769B0(pDrlgRoom: D2DrlgRoomStrc): void {
for (let i = 0; i < pDrlgRoom.nRoomsNear; i += 1) {
const pCurrentRoomEx = pDrlgRoom.ppRoomsNear[i]!
if (pCurrentRoomEx !== pDrlgRoom) {
if (pCurrentRoomEx.pLogicalRoomInfo) {
if (!DRLG_CheckNotOverlappingUsingManhattanDistance(pDrlgRoom.pDrlgCoord, pCurrentRoomEx.pDrlgCoord, 1)) {
for (let j = pDrlgRoom.pDrlgCoord.nPosX; j <= pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth; j += 1) {
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, j, pDrlgRoom.pDrlgCoord.nPosY)
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, j, (pDrlgRoom.pDrlgCoord.nHeight + pDrlgRoom.pDrlgCoord.nPosY) | 0)
}
for (let j = pDrlgRoom.pDrlgCoord.nPosY; j <= pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight; j += 1) {
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, pDrlgRoom.pDrlgCoord.nPosX, j)
sub_6FD76A90(pDrlgRoom, pCurrentRoomEx, (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0, j)
}
}
}
}
}
}
//D2Common.0x6FD76A90
function sub_6FD76A90(pDrlgRoom1: D2DrlgRoomStrc, pDrlgRoom2: D2DrlgRoomStrc, nX: number, nY: number): void {
if (DRLGROOM_AreXYInsideCoordinatesOrOnBorder(pDrlgRoom2.pDrlgCoord, nX, nY)) {
const pRoomCoordList1 = sub_6FD77110(pDrlgRoom1, Math.imul(nX, 5), Math.imul(nY, 5))
const pRoomCoordList2 = sub_6FD77110(pDrlgRoom2, Math.imul(nX, 5), Math.imul(nY, 5))
if (!pRoomCoordList1 || !pRoomCoordList2) throw new Error('sub_6FD76A90: NULL D2RoomCoordListStrc dereference')
const nIndex1 = pRoomCoordList1.nIndex
const nIndex2 = pRoomCoordList2.nIndex
if (nIndex2 && nIndex1) {
if (
pDrlgRoom2.pLevel.nLevelId === pDrlgRoom1.pLevel.nLevelId &&
nIndex2 !== nIndex1 &&
pRoomCoordList2.bNode === pRoomCoordList1.bNode
) {
sub_6FD76B90(pDrlgRoom1, nIndex1, nIndex2, pRoomCoordList2.bNode)
}
}
}
}
//D2Common.0x6FD76B90
function sub_6FD76B90(pDrlgRoom: D2DrlgRoomStrc, nIndex1: number, nIndex2: number, bNode: number): void {
if (pDrlgRoom.pLogicalRoomInfo === null || pDrlgRoom.pLogicalRoomInfo.dwFlags & DRLGLOGIC_ROOMINFO_HAS_COORD_LIST) {
return
}
let pRoomCoordList = pDrlgRoom.pLogicalRoomInfo.pCoordList
if (pRoomCoordList) {
let bContinue = false
let nSteps = 0
do {
nSteps = chainStep(nSteps)
if (pRoomCoordList.nIndex === nIndex1) {
bContinue = true
pRoomCoordList.nIndex = nIndex2
}
pRoomCoordList = pRoomCoordList.pNext
} while (pRoomCoordList)
if (bContinue) {
for (let i = 0; i < pDrlgRoom.nRoomsNear; i += 1) {
const pRoomNear = pDrlgRoom.ppRoomsNear[i]!
if (pRoomNear !== pDrlgRoom && pRoomNear.pLevel.nLevelId === pDrlgRoom.pLevel.nLevelId) {
sub_6FD76B90(pRoomNear, nIndex1, nIndex2, bNode)
}
}
}
}
}
//D2Common.0x6FD76C20
export function DRLG_CheckLayer1ButNotWallObject(pTileData: D2DrlgTileDataStrc): boolean {
if (GetMapTileLayer(pTileData.dwFlags) === 1) {
if (pTileData.nTileType !== TILETYPE_ROOF) {
return (pTileData.dwFlags & MAPTILE_OBJECT_WALL) === 0
}
}
return false
}
//D2Common.0x6FD76C50
function DRLGLOGIC_SetCoordListForTiles(pDrlgRoom: D2DrlgRoomStrc): void {
const pTiles = tileGridOf(pDrlgRoom).pTiles
if (pTiles.nWalls) {
for (let i = 0; i < pTiles.nWalls; i += 1) {
const pWallTileData = tileAt(pTiles.pWallTiles, i, 'DRLGLOGIC_SetCoordListForTiles')
const pLogicalRoomInfo = pDrlgRoom.pLogicalRoomInfo
if (pLogicalRoomInfo && !(pLogicalRoomInfo.dwFlags & DRLGLOGIC_ROOMINFO_HAS_COORD_LIST)) {
pWallTileData.unk0x10 =
coordListFromCell(
pLogicalRoomInfo,
DRLGGRID_GetGridEntry(pLogicalRoomInfo.pIndexY, pWallTileData.nPosX, pWallTileData.nPosY),
) ?? 0
} else {
pWallTileData.unk0x10 = 0
}
}
}
}
//D2Common.0x6FD76CF0
function DRLGLOGIC_AssignCoordListsForGrids(
pDrlgRoom: D2DrlgRoomStrc,
pDrlgCoordList: D2DrlgLogicalRoomInfoStrc,
_nLists: number,
): void {
DRLGGRID_InitializeGridCells(pDrlgCoordList.pIndexY, (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0, (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0)
const nWidth = (pDrlgRoom.pDrlgCoord.nWidth + 1) | 0
const nHeight = (pDrlgRoom.pDrlgCoord.nHeight + 1) | 0
for (let nY = 0; nY < nHeight; nY += 1) {
for (let nX = 0; nX < nWidth; nX += 1) {
const nFlags = DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexX, nX, nY)
const nIndex = nFlags & 0xfffffff
if (!DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexY, nX, nY)) {
const pRoomCoordList = newRoomCoordList()
const hRoomCoordList = pDrlgCoordList.ppCoordListHandles.push(pRoomCoordList)
pRoomCoordList.nIndex = nIndex
pRoomCoordList.bNode = (nFlags & 0x20000000) === 0x20000000 ? 1 : 0
pRoomCoordList.pNext = pDrlgCoordList.pCoordList
pDrlgCoordList.pCoordList = pRoomCoordList
const pBox0 = pRoomCoordList.pBox[0]
const pBox1 = pRoomCoordList.pBox[1]
pBox0.nPosX = nX
pBox0.nPosY = nY
let nTmpWidth: number
for (nTmpWidth = nX; nTmpWidth < nWidth; nTmpWidth += 1) {
if (nIndex !== (DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexX, nTmpWidth, nY) & 0xfffffff)) {
break
}
if (DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexY, nTmpWidth, nY)) {
break
}
}
pBox0.nWidth = nTmpWidth
let bBreak = false
let nTmpHeight: number
for (nTmpHeight = nY; nTmpHeight < nHeight; nTmpHeight += 1) {
for (let i = nX; i < pBox0.nWidth; i += 1) {
if (
nIndex !== (DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexX, i, nTmpHeight) & 0xfffffff) ||
DRLGGRID_GetGridEntry(pDrlgCoordList.pIndexY, i, nTmpHeight)
) {
bBreak = true
break
}
}
if (bBreak) {
break
}
}
pBox0.nHeight = nTmpHeight
for (let j = pBox0.nPosY; j < pBox0.nHeight; j += 1) {
for (let i = pBox0.nPosX; i < pBox0.nWidth; i += 1) {
DRLGGRID_AlterGridFlag(pDrlgCoordList.pIndexY, i, j, hRoomCoordList, FLAG_OPERATION_OVERWRITE)
}
}
pBox0.nPosY = (pBox0.nPosY + pDrlgRoom.pDrlgCoord.nPosY) | 0
pBox0.nHeight = (pBox0.nHeight + pDrlgRoom.pDrlgCoord.nPosY) | 0
pBox0.nPosX = (pBox0.nPosX + pDrlgRoom.pDrlgCoord.nPosX) | 0
pBox0.nWidth = (pBox0.nWidth + pDrlgRoom.pDrlgCoord.nPosX) | 0
pBox1.nPosX = pBox0.nPosX
pBox1.nPosY = pBox0.nPosY
pBox1.nWidth = pBox0.nWidth
pBox1.nHeight = pBox0.nHeight
nTmpWidth = (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0
if (pBox1.nWidth >= nTmpWidth) {
pBox1.nWidth = nTmpWidth
}
nTmpHeight = (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0
if (pBox1.nHeight >= nTmpHeight) {
pBox1.nHeight = nTmpHeight
}
if (pBox1.nPosX >= nTmpWidth || pBox1.nPosY >= nTmpHeight) {
pBox1.nPosX = 0
pBox1.nPosY = 0
pBox1.nWidth = 0
pBox1.nHeight = 0
}
}
}
}
}
//D2Common.0x6FD76F90
export function DRLGLOGIC_AllocCoordLists(pDrlgRoom: D2DrlgRoomStrc): void {
const pLogicalRoomInfo = newLogicalRoomInfo()
pDrlgRoom.pLogicalRoomInfo = pLogicalRoomInfo
pLogicalRoomInfo.dwFlags |= DRLGLOGIC_ROOMINFO_HAS_COORD_LIST
pDrlgRoom.pLevel.nCoordLists = 1
pLogicalRoomInfo.nLists = 1
const pCoordList = newRoomCoordList()
pLogicalRoomInfo.pCoordList = pCoordList
pCoordList.nIndex = pDrlgRoom.pLevel.nCoordLists
pCoordList.pBox[0].nPosY = pDrlgRoom.pDrlgCoord.nPosY
pCoordList.pBox[0].nHeight = (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0
pCoordList.pBox[0].nPosX = pDrlgRoom.pDrlgCoord.nPosX
pCoordList.pBox[0].nWidth = (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0
pCoordList.pBox[1].nPosY = pDrlgRoom.pDrlgCoord.nPosY
pCoordList.pBox[1].nHeight = (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0
pCoordList.pBox[1].nPosX = pDrlgRoom.pDrlgCoord.nPosX
pCoordList.pBox[1].nWidth = (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0
}
//D2Common.0x6FD77080
export function DRLGLOGIC_GetRoomCoordListIndex(pDrlgRoom: D2DrlgRoomStrc, nX: number, nY: number): number {
const pRoomCoordList = sub_6FD77110(pDrlgRoom, nX, nY)
if (pRoomCoordList) {
return pRoomCoordList.nIndex
}
return -1
}
//D2Common.0x6FD77110
export function sub_6FD77110(pDrlgRoom: D2DrlgRoomStrc, nX: number, nY: number): D2RoomCoordListStrc | null {
const pLogicalRoomInfo = pDrlgRoom.pLogicalRoomInfo
if (!pLogicalRoomInfo) throw new Error('D2_ASSERT(pDrlgRoom->pLogicalRoomInfo) (sub_6FD77110)')
if (pLogicalRoomInfo.dwFlags & DRLGLOGIC_ROOMINFO_HAS_COORD_LIST) {
return pLogicalRoomInfo.pCoordList
} else {
return coordListFromCell(
pLogicalRoomInfo,
DRLGGRID_GetGridEntry(
pLogicalRoomInfo.pIndexY,
((nX / 5) | 0) - pDrlgRoom.pDrlgCoord.nPosX,
((nY / 5) | 0) - pDrlgRoom.pDrlgCoord.nPosY,
),
)
}
}
//D2Common.0x6FD77190
export function DRLGLOGIC_GetRoomCoordList(pDrlgRoom: D2DrlgRoomStrc): D2RoomCoordListStrc | null {
if (!pDrlgRoom.pLogicalRoomInfo) throw new Error('D2_ASSERT(pDrlgRoom->pLogicalRoomInfo) (DRLGLOGIC_GetRoomCoordList)')
return pDrlgRoom.pLogicalRoomInfo.pCoordList
}

826
src/game/drlg/drlg-map.ts Normal file
View File

@ -0,0 +1,826 @@
/**
* Act I outdoor level maps from the DRLG port (M5 adapter).
*
* The 1:1 port of D2MOO's DRLG (src/game/drlg/drlg-*.ts) produces the level exactly as D2Common builds
* it: rooms, their tile lists (DRLGROOMTILE_AddRoomMapTiles), preset units and warps. This module turns
* one generated level into what the rest of the game consumes:
* - a DS1-shaped canvas ({@link Ds1}) for the isometric renderer (`buildIsoMapScene`), one cell per
* level tile, holding the exact (style, sequence, type) of every tile D2 placed;
* - the DT1 libraries those tiles come from, with a per-cell library mask so the renderer only draws
* variants from the library D2 picked the tile from;
* - DS1-shaped objects (the same (type, id) encoding a preset DS1 uses, so the bake resolves them
* through its existing object path);
* - the level's entrances (seams with their real sides from the DRLG level links, and warps from the
* UNIT_TILE preset units D2 creates for them) and SuperUnique landmarks.
*
* Nothing here decides anything about the layout: every value comes from the verified DRLG output
* (the schema-3 dump, identical for the native oracle and the port), and anything unexpected throws.
* The input is the normalized {@link DrlgLevelInput} so the same code maps the oracle's JSON and the
* port's in-memory dump.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../../formats/ds1.ts'
import type { Dt1 } from '../../formats/dt1.ts'
import { dt1MaskAllows } from '../d2map.ts'
import type { DrlgDump, DumpCoord, DumpLevel, DumpTile, DumpTileRef } from './drlg-dump.ts'
import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts'
import { DRLGPRESET_GetObjectIndexFromObjPreset } from './drlg-preset.ts'
import { normalizeDrlgPath } from './drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, DATATBLS_GetLvlTypesTxtRecord, DATATBLS_GetMonPresetTxtActSection } from './drlg-tables.ts'
import type { DrlgTables } from './drlg-tables.ts'
import {
ALTDIR_EAST, ALTDIR_NORTH, ALTDIR_SOUTH, ALTDIR_WEST, DRLGTYPE_OUTDOOR, GRID2_LVL_LINK,
} from './drlg-types.ts'
/** Tiles per outdoor grid cell (the 8x8 blocks of D2DrlgOutdoorInfoStrc.pGrid). */
const TILES_PER_GRID_CELL = 8
/** Sub-tiles per tile (DUNGEON_GameTileToSubtileCoords). */
const SUBTILES_PER_TILE = 5
// D2MapTileFlags (D2DrlgDrlg.h)
const MAPTILE_HIDDEN = 0x8
const MAPTILE_LAYER_SHIFT = 14
const MAPTILE_LAYER_MASK = 0x7
// D2TileType (D2CMP.h)
const TILETYPE_FLOOR = 0
const TILETYPE_WALL_TOP_CORNER_RIGHT = 3
const TILETYPE_WALL_TOP_CORNER_LEFT = 4
const TILETYPE_SHADOW = 13
// D2C_UnitTypes
const UNIT_MONSTER = 1
const UNIT_OBJECT = 2
const UNIT_TILE = 5
/** DS1 object `type` values (the encoding DRLGPRESET_ParseDS1File reads). */
const DS1_TYPE_MONSTER = 1
const DS1_TYPE_OBJECT = 2
/** Object ids below this are ObjPreset slots; `objects.txt id + 150` otherwise (DRLGPRESET_ParseDS1File, v > 5). */
const DS1_OBJPRESET_SLOTS = 150
/**
* Objects.txt class D2Game refuses to create from a preset unit (SUnit.cpp:1003, SUNIT_CreatePresetUnit:
* `if (nClassId == 573) return`), so it never exists in game.
*/
const OBJECT_CLASS_INVALID_PRESET = 573
/**
* MonPlace.txt rows D2Game turns into a fixed monster at the preset position instead of a random pack
* (D2Game MonsterRegion.cpp:243-300, `switch (nMonPlace)`), mapped to the monster planner's landmark id.
* Only the cases reachable from Act I outdoor presets are listed: 5 -> MONSTER_BLOODRAVEN.
*/
const MONPLACE_LANDMARKS: ReadonlyMap<number, string> = new Map([[5, 'Blood Raven']])
/** Axis-aligned rectangle in world tiles (D2DrlgCoordStrc). */
export interface DrlgRect {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
/** Which array of the owner room's D2DrlgRoomTilesStrc a tile lives in. */
export type DrlgTileKind = 'wall' | 'floor' | 'shadow'
/** One D2DrlgTileDataStrc, placed in level coordinates. */
export interface DrlgTileInput {
/** Identity of the tile data (owner level, room, array, index), so owned and linked copies dedupe. */
readonly key: string
readonly kind: DrlgTileKind
/** Level-local tile position: owner room position + nPosX/nPosY - level position (0..W, 0..H). */
readonly x: number
readonly y: number
/** D2DrlgTileDataStrc.dwFlags (D2MapTileFlags). */
readonly flags: number
/** D2DrlgTileDataStrc.nTileType. */
readonly tileType: number
/** pTile: the DT1 tile D2 picked (normalized library path, tile index), or null when none matched. */
readonly tile: { readonly library: string; readonly index: number } | null
}
/** One D2PresetUnitStrc of a room, after DRLGROOMTILE_AddRoomMapTiles. */
export interface DrlgUnitInput {
/** Room index in the level's room chain. */
readonly room: number
/** D2C_UnitTypes. */
readonly type: number
/** nIndex as D2 stores it (after DRLGPRESET_ParseDS1File's MonPreset / ObjPreset conversion). */
readonly index: number
readonly mode: number
/** Level-local sub-tile position (room-relative nXpos/nYpos + 5 * room offset). */
readonly subX: number
readonly subY: number
readonly spawned: number
}
/** One D2RoomTileStrc (a warp of a room, sub_6FD77F00). */
export interface DrlgWarpInput {
readonly room: number
/** pLvlWarpTxtRecord->dwLevelId: the LvlWarp.txt `Id`, the class id of the warp's UNIT_TILE unit. */
readonly lvlWarpId: number
/** pDrlgRoom->pLevel->nLevelId of the destination room. */
readonly toLevelId: number
}
/** One level-to-level D2DrlgOrthStrc of pOutdoors->pRoomData. */
export interface DrlgOrthInput {
readonly level: number
/** ALTDIR_*: where the neighbour lies (0 west, 1 north, 2 east, 3 south). */
readonly dir: number
readonly box: DrlgRect
}
/** Everything the adapter reads about one generated level. */
export interface DrlgLevelInput {
readonly levelId: number
readonly drlgType: number
readonly levelType: number
readonly coord: DrlgRect
/** pOutdoors->dwFlags (D2DrlgOutdoorInfoStrc: link directions, river bank, ...). */
readonly flags: number
readonly orths: readonly DrlgOrthInput[]
/** pOutdoors->pGrid[2] (packed grid-2 info) as dumped at the levelGrid stage. */
readonly grid2: { readonly w: number; readonly h: number; readonly cells: readonly number[] }
readonly tiles: readonly DrlgTileInput[]
readonly units: readonly DrlgUnitInput[]
readonly warps: readonly DrlgWarpInput[]
}
/** Tables the mapping needs besides the DRLG output. */
export interface DrlgMapContext {
readonly tables: DrlgTables
/** Parsed DT1 libraries keyed by {@link normalizeDrlgPath}. */
readonly dt1: ReadonlyMap<string, Dt1>
/** SuperUniques.txt `Superunique` column, by row. */
readonly superUniqueIds: readonly string[]
}
/** Which edge of the level an entrance sits on (world-graph `Side`). */
export type DrlgSide = 'north' | 'east' | 'south' | 'west'
/**
* A way into or out of the level: the fields of a `WildernessEntrance` (what the bake's
* buildSceneLinks reads) except the display label, which the bake adds, plus `span` for seams.
*/
export interface DrlgEntrance {
/** Anchor cell (tiles, level-local). */
readonly x: number
readonly y: number
/** One step inside from the anchor. */
readonly interiorX: number
readonly interiorY: number
readonly toLevelId: number
readonly side: DrlgSide | null
readonly kind: 'gate' | 'preset'
/**
* Seams only: the stretch of this level's edge (tiles along the side, `from` inclusive, `to`
* exclusive) where the opening is. The bake searches the walkable gap inside it.
*/
readonly span?: { readonly from: number; readonly to: number }
/** Warps only: the UNIT_TILE position in sub-tiles (level-local) and its LvlWarp.txt Id. */
readonly warp?: { readonly subX: number; readonly subY: number; readonly lvlWarpId: number }
}
/** A fixed monster the planner anchors to its preset position. */
export interface DrlgLandmark {
/** The monster planner's id (SuperUniques.txt `Superunique`, or 'Blood Raven'). */
readonly id: string
readonly tileX: number
readonly tileY: number
readonly subX: number
readonly subY: number
}
/** One tile reference written into the canvas, with the library D2 took it from. */
export interface DrlgCellRef {
readonly x: number
readonly y: number
readonly kind: DrlgTileKind
readonly style: number
readonly sequence: number
readonly type: number
/** Index into {@link DrlgLevelMap.dt1Names}. */
readonly library: number
readonly hidden: boolean
}
export interface DrlgLevelMapStats {
readonly walls: number
readonly floors: number
readonly shadows: number
/** Type-4 halves skipped because the renderer draws them with their type-3 tile. */
readonly cornerCompanions: number
/** Tiles whose pTile is null (D2CMP found no tile): D2 draws nothing for them. */
readonly withoutTile: number
/** Linked tiles of other rooms drawn inside this level's canvas. */
readonly linkedInCanvas: number
/** Linked tiles outside the canvas (belong to the neighbour's canvas). */
readonly linkedOutside: number
readonly objects: number
readonly monsters: number
readonly skippedInvalidObjects: number
}
/** One mapped level. */
export interface DrlgLevelMap {
readonly levelId: number
readonly coord: DrlgRect
readonly flags: number
/** The canvas: (width + 1) x (height + 1) cells, like a DS1 of the level's size. */
readonly ds1: Ds1
/** DT1 libraries (normalized archive paths) in renderer library order; per-cell `dt1Mask` bits index it. */
readonly dt1Names: readonly string[]
readonly entrances: readonly DrlgEntrance[]
readonly landmarks: readonly DrlgLandmark[]
/** Every tile reference written into the canvas (for the DT1-mask ambiguity audit). */
readonly refs: readonly DrlgCellRef[]
readonly stats: DrlgLevelMapStats
}
/**
* DT1 libraries DRLGROOMTILE_LoadDT1FilesForRoom loads for every room regardless of its mask
* (DrlgRoomTile.cpp:1249-1256): they come first so a cell mask can always name them.
*/
export const DRLG_UNIVERSAL_DT1: readonly string[] = [
'DATA\\GLOBAL\\TILES\\Act1\\Outdoors\\Blank.dt1',
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\InvisWal.dt1',
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1',
].map(normalizeDrlgPath)
/**
* The Act I levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending.
*
* @param tables - the DRLG tables.
* @returns level ids.
*/
export function act1OutdoorLevelIds(tables: DrlgTables): number[] {
const out: number[] = []
for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) {
if (DRLG_GetActNoFromLevelId(id) !== 0) break
if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id)
}
return out
}
const SIDE_OF_ALTDIR: Readonly<Record<number, DrlgSide>> = {
[ALTDIR_WEST]: 'west',
[ALTDIR_NORTH]: 'north',
[ALTDIR_EAST]: 'east',
[ALTDIR_SOUTH]: 'south',
}
function sideOfAltDir(dir: number, what: string): DrlgSide {
const side = SIDE_OF_ALTDIR[dir]
if (side === undefined) throw new Error(`${what}: direction ${dir} is not an ALTDIR`)
return side
}
function emptyWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** Mutable per-cell collector. */
interface CellBuild {
walls: { layer: number; order: number; wall: Ds1Wall }[]
floors: { layer: number; order: number; floor: Ds1Floor }[]
shadows: Ds1Floor[]
mask: number
}
/**
* The DT1 library order for a level: the universal libraries, then the libraries of the level's
* LvlTypes row in `File 1..32` order. Only libraries some tile uses are listed (plus the universal
* three, which DRLGROOMTILE_LoadDT1FilesForRoom always loads).
*/
function libraryOrder(tables: DrlgTables, levelType: number, used: ReadonlySet<string>, levelId: number): string[] {
const order: string[] = [...DRLG_UNIVERSAL_DT1]
const listed = new Set(order)
for (const f of DATATBLS_GetLvlTypesTxtRecord(tables, levelType).szFile) {
if (f.length <= 1) continue
const key = normalizeDrlgPath(f)
if (listed.has(key) || !used.has(key)) continue
order.push(key)
listed.add(key)
}
for (const lib of used) {
if (!listed.has(lib)) {
throw new Error(`level ${levelId}: tile library ${lib} is neither universal nor in LvlTypes row ${levelType}`)
}
}
if (order.length > 32) throw new Error(`level ${levelId}: ${order.length} DT1 libraries do not fit a 32-bit cell mask`)
return order
}
/**
* Map one generated Act I outdoor level.
*
* @param input - the level, from the DRLG dump.
* @param ctx - tables, DT1 libraries and names.
* @returns the mapped level.
*/
export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): DrlgLevelMap {
const { levelId, coord } = input
if (input.drlgType !== DRLGTYPE_OUTDOOR) throw new Error(`level ${levelId}: not an outdoor level (DrlgType ${input.drlgType})`)
const nAct = DRLG_GetActNoFromLevelId(levelId)
const cellsX = coord.width + 1
const cellsY = coord.height + 1
// ---- tiles -> canvas ----
const cells: CellBuild[] = []
for (let i = 0; i < cellsX * cellsY; i += 1) cells.push({ walls: [], floors: [], shadows: [], mask: 0 })
const seen = new Set<string>()
const used = new Set<string>()
const placed: { t: DrlgTileInput; style: number; sequence: number; type: number; hidden: boolean }[] = []
let cornerCompanions = 0
let withoutTile = 0
let linkedInCanvas = 0
let linkedOutside = 0
const topCornerRights = new Set<string>()
const topCornerLefts: { t: DrlgTileInput; style: number; sequence: number }[] = []
for (const t of input.tiles) {
if (seen.has(t.key)) continue
seen.add(t.key)
const linked = !t.key.startsWith(`${levelId}/`)
if (t.x < 0 || t.y < 0 || t.x >= cellsX || t.y >= cellsY) {
if (!linked) throw new Error(`level ${levelId}: owned tile ${t.key} at (${t.x}, ${t.y}) is outside the ${cellsX}x${cellsY} canvas`)
linkedOutside += 1
continue
}
if (linked) linkedInCanvas += 1
if (t.tile === null) {
withoutTile += 1
continue
}
const lib = ctx.dt1.get(t.tile.library)
if (lib === undefined) throw new Error(`level ${levelId}: tile ${t.key} uses DT1 ${t.tile.library}, which is not loaded`)
const header = lib.tiles[t.tile.index]
if (header === undefined) throw new Error(`level ${levelId}: tile ${t.key}: ${t.tile.library} has no tile #${t.tile.index}`)
if (header.type !== t.tileType) {
throw new Error(`level ${levelId}: tile ${t.key}: nTileType ${t.tileType} but DT1 tile #${t.tile.index} of ${t.tile.library} is type ${header.type}`)
}
const kindOk = t.kind === 'floor' ? header.type === TILETYPE_FLOOR
: t.kind === 'shadow' ? header.type === TILETYPE_SHADOW
: header.type !== TILETYPE_FLOOR && header.type !== TILETYPE_SHADOW
if (!kindOk) throw new Error(`level ${levelId}: tile ${t.key} is a ${t.kind} of DT1 type ${header.type}`)
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.
topCornerLefts.push({ t, style: header.style, sequence: header.sequence })
continue
}
if (header.type === TILETYPE_WALL_TOP_CORNER_RIGHT) topCornerRights.add(`${t.x},${t.y},${header.style},${header.sequence}`)
used.add(t.tile.library)
placed.push({ t, style: header.style, sequence: header.sequence, type: header.type, hidden: (t.flags & MAPTILE_HIDDEN) !== 0 })
}
for (const c of topCornerLefts) {
if (!topCornerRights.has(`${c.t.x},${c.t.y},${c.style},${c.sequence}`)) {
throw new Error(`level ${levelId}: top-corner-left tile ${c.t.key} at (${c.t.x}, ${c.t.y}) has no top-corner-right partner`)
}
cornerCompanions += 1
}
const dt1Names = libraryOrder(ctx.tables, input.levelType, used, levelId)
const libIndex = new Map(dt1Names.map((name, i) => [name, i]))
const refs: DrlgCellRef[] = []
let order = 0
let walls = 0
let floors = 0
let shadows = 0
for (const p of placed) {
const cell = cells[p.t.y * cellsX + p.t.x]!
const library = libIndex.get(p.t.tile!.library)!
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 } })
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 })
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 } })
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 })
}
let wallLayers = 1
let floorLayers = 1
let shadowLayers = 1
for (const c of cells) {
wallLayers = Math.max(wallLayers, c.walls.length)
floorLayers = Math.max(floorLayers, c.floors.length)
shadowLayers = Math.max(shadowLayers, c.shadows.length)
}
const byLayer = <T extends { layer: number; order: number }>(a: T, b: T): number => a.layer - b.layer || a.order - b.order
const rows: Ds1Cell[][] = []
for (let y = 0; y < cellsY; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < cellsX; x += 1) {
const c = cells[y * cellsX + x]!
const cw = c.walls.sort(byLayer).map(w => w.wall)
const cf = c.floors.sort(byLayer).map(f => f.floor)
const cs = [...c.shadows]
while (cw.length < wallLayers) cw.push(emptyWall())
while (cf.length < floorLayers) cf.push(emptyFloor())
while (cs.length < shadowLayers) cs.push(emptyFloor())
row.push({ walls: cw, floors: cf, shadows: cs, substitutions: [], ...(c.mask !== 0 ? { dt1Mask: c.mask >>> 0 } : {}) })
}
rows.push(row)
}
// ---- units -> DS1 objects, landmarks, warp entrances ----
const objSlotOf = new Map<number, number>()
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
objSlotOf.set(DRLGPRESET_GetObjectIndexFromObjPreset(nAct, slot), slot) // lowest slot wins
}
const monSection = DATATBLS_GetMonPresetTxtActSection(ctx.tables, nAct)
if (monSection === null) throw new Error(`level ${levelId}: MonPreset has no act ${nAct + 1} section`)
const nMonStats = ctx.tables.nMonStatsTxtRecordCount
const nSU = ctx.tables.nSuperUniquesTxtRecordCount
const monIdOf = new Map<number, number>()
for (let i = monSection.count - 1; i >= 0; i -= 1) {
const r = ctx.tables.monPreset[monSection.start + i]!
let unit: number
switch (r.nType) {
case 0: unit = r.wPlace + nSU + nMonStats; break
case 1: unit = r.wPlace; break
case 2: unit = r.wPlace + nMonStats; break
default: continue // DRLGPRESET_ParseDS1File maps it to -1, which is never allocated
}
monIdOf.set(unit, i) // lowest DS1 id wins (duplicates carry the same Place)
}
const objects: Ds1Object[] = []
const landmarks: DrlgLandmark[] = []
const entrances: DrlgEntrance[] = []
let nObjects = 0
let nMonsters = 0
let skippedInvalidObjects = 0
for (const u of input.units) {
switch (u.type) {
case UNIT_OBJECT: {
if (u.index === OBJECT_CLASS_INVALID_PRESET) { skippedInvalidObjects += 1; break }
if (u.index < 0) throw new Error(`level ${levelId}: object unit with class ${u.index}`)
const slot = objSlotOf.get(u.index)
objects.push({ type: DS1_TYPE_OBJECT, id: slot ?? u.index + DS1_OBJPRESET_SLOTS, x: u.subX, y: u.subY, flags: u.spawned })
nObjects += 1
break
}
case UNIT_MONSTER: {
const id = monIdOf.get(u.index)
if (id === undefined) throw new Error(`level ${levelId}: monster unit ${u.index} has no act ${nAct + 1} MonPreset row`)
objects.push({ type: DS1_TYPE_MONSTER, id, x: u.subX, y: u.subY, flags: u.spawned })
nMonsters += 1
const tileX = Math.floor(u.subX / SUBTILES_PER_TILE)
const tileY = Math.floor(u.subY / SUBTILES_PER_TILE)
if (u.index >= nMonStats && u.index < nMonStats + nSU) {
const su = ctx.superUniqueIds[u.index - nMonStats]
if (su === undefined || su === '') throw new Error(`level ${levelId}: SuperUnique row ${u.index - nMonStats} has no id`)
landmarks.push({ id: su, tileX, tileY, subX: u.subX, subY: u.subY })
} else if (u.index >= nMonStats + nSU) {
const special = MONPLACE_LANDMARKS.get(u.index - nMonStats - nSU)
if (special !== undefined) landmarks.push({ id: special, tileX, tileY, subX: u.subX, subY: u.subY })
}
break
}
case UNIT_TILE: {
const matches = input.warps.filter(w => w.room === u.room && w.lvlWarpId === u.index)
const to = [...new Set(matches.map(w => w.toLevelId))]
if (to.length !== 1) {
throw new Error(`level ${levelId} room ${u.room}: UNIT_TILE ${u.index} matches ${to.length} warp destinations (${to.join(', ')})`)
}
const cx = Math.floor(u.subX / SUBTILES_PER_TILE)
const cy = Math.floor(u.subY / SUBTILES_PER_TILE)
entrances.push({
x: cx,
y: cy,
interiorX: cx,
interiorY: cy,
toLevelId: to[0]!,
side: null,
kind: 'preset',
warp: { subX: u.subX, subY: u.subY, lvlWarpId: u.index },
})
break
}
default:
throw new Error(`level ${levelId} room ${u.room}: unexpected preset unit type ${u.type} (index ${u.index})`)
}
}
// ---- seams ----
entrances.push(...seamEntrances(input))
const ds1: Ds1 = {
version: 18,
width: cellsX,
height: cellsY,
act: nAct + 1,
substitutionType: 0,
wallLayers,
floorLayers,
cells: rows,
objects,
npcPathOffset: null,
}
return {
levelId,
coord,
flags: input.flags,
ds1,
dt1Names,
entrances,
landmarks,
refs,
stats: {
walls, floors, shadows, cornerCompanions, withoutTile, linkedInCanvas, linkedOutside,
objects: nObjects, monsters: nMonsters, skippedInvalidObjects,
},
}
}
/** How far the renderer's tile choice can stray from D2's for one mapped level. */
export interface DrlgDt1Ambiguity {
/** DT1 libraries of the level ({@link DrlgLevelMap.dt1Names}). */
readonly libraries: number
/** Tile references written into the canvas. */
readonly refs: number
/**
* References whose exact (style, sequence, type) group under their cell's DT1 mask holds more than
* one tile. The renderer draws the variant with its own per-cell hash (scene fidelity 'layout'); D2
* rolled it with the room seed (DRLGROOMTILE_GetTileCache), and the port records that roll.
*/
readonly variantGroups: number
/** References whose group under the cell mask spans more than one library: the renderer may draw a tile of a library D2 did not take it from. */
readonly crossLibrary: number
/** The same count with one pool for the whole level (no cell masks): what the cell masks prevent. */
readonly crossLibraryLevelWide: number
}
/**
* The DT1-mask ambiguity audit of a mapped level: resolves every tile reference the way
* `buildIsoMapScene` does (exact style:sequence:type key, {@link dt1MaskAllows}) and counts the
* references whose candidates are not pinned down to D2's library.
*
* @param map - the mapped level.
* @param libraries - the parsed libraries of `map.dt1Names`, in that order.
* @returns the counts.
*/
export function auditDrlgDt1Ambiguity(map: DrlgLevelMap, libraries: readonly Dt1[]): DrlgDt1Ambiguity {
if (libraries.length !== map.dt1Names.length) {
throw new Error(`level ${map.levelId}: ${libraries.length} libraries for ${map.dt1Names.length} DT1 names`)
}
// style:sequence:type -> tiles per library
const groups = new Map<string, number[]>()
libraries.forEach((library, at) => {
for (const tile of library.tiles) {
const key = `${tile.style}:${tile.sequence}:${tile.type}`
let counts = groups.get(key)
if (counts === undefined) {
counts = new Array<number>(libraries.length).fill(0)
groups.set(key, counts)
}
counts[at]! += 1
}
})
let variantGroups = 0
let crossLibrary = 0
let crossLibraryLevelWide = 0
for (const ref of map.refs) {
const counts = groups.get(`${ref.style}:${ref.sequence}:${ref.type}`)
if (counts === undefined || counts[ref.library] === 0) {
throw new Error(`level ${map.levelId}: ${ref.kind} ${ref.style}:${ref.sequence}:${ref.type} at (${ref.x}, ${ref.y}) is not in its library ${map.dt1Names[ref.library]}`)
}
const mask = map.ds1.cells[ref.y]?.[ref.x]?.dt1Mask
if (mask === undefined) throw new Error(`level ${map.levelId}: cell (${ref.x}, ${ref.y}) holds a tile but has no DT1 mask`)
let tilesInCell = 0
let librariesInCell = 0
let librariesInLevel = 0
counts.forEach((n, library) => {
if (n === 0) return
librariesInLevel += 1
if (!dt1MaskAllows(mask, library)) return
librariesInCell += 1
tilesInCell += n
})
if (tilesInCell > 1) variantGroups += 1
if (librariesInCell > 1) crossLibrary += 1
if (librariesInLevel > 1) crossLibraryLevelWide += 1
}
return { libraries: libraries.length, refs: map.refs.length, variantGroups, crossLibrary, crossLibraryLevelWide }
}
/**
* The level's seams to its outdoor/preset neighbours, one per level link (pOutdoors->pRoomData).
*
* Outdoor-outdoor links are the grid-2 cells DRLGOUTPLACE_PlaceAct1245OutdoorBorders flags
* GRID2_LVL_LINK: the border cell in the middle of each linked vertex edge, which gets the open border
* preset (picked file 3, or 4 on the Burial Grounds). Links whose vertex edge carries flag 2 (the town
* transition, the Monastery Gate approach) get no border cell: their opening is the neighbour-specific
* preset, somewhere along the shared edge, so the whole shared stretch is the span.
*/
function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] {
const { levelId, coord, grid2 } = input
const out: DrlgEntrance[] = []
if (grid2.w * TILES_PER_GRID_CELL !== coord.width || grid2.h * TILES_PER_GRID_CELL !== coord.height) {
throw new Error(`level ${levelId}: grid-2 ${grid2.w}x${grid2.h} does not cover ${coord.width}x${coord.height} tiles`)
}
const linkCellsOf = new Map<number, { gx: number; gy: number }[]>()
for (let gy = 0; gy < grid2.h; gy += 1) {
for (let gx = 0; gx < grid2.w; gx += 1) {
if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue
const onW = gx === 0
const onE = gx === grid2.w - 1
const onN = gy === 0
const onS = gy === grid2.h - 1
if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) {
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`)
}
const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH
// World tiles covered by the cell along its side.
const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH
const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL)
const to = from + TILES_PER_GRID_CELL
const owners = input.orths.filter(o => {
if (o.dir !== dir) return false
const lo = horizontal ? o.box.x : o.box.y
const hi = lo + (horizontal ? o.box.width : o.box.height)
return from >= lo && to <= hi
})
if (owners.length !== 1) {
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`)
}
const list = linkCellsOf.get(owners[0]!.level) ?? []
list.push({ gx, gy })
linkCellsOf.set(owners[0]!.level, list)
}
}
for (const o of input.orths) {
const side = sideOfAltDir(o.dir, `level ${levelId} link to ${o.level}`)
const horizontal = side === 'north' || side === 'south'
const cellsOnLink = linkCellsOf.get(o.level) ?? []
let from: number
let to: number
if (cellsOnLink.length > 1) {
throw new Error(`level ${levelId}: ${cellsOnLink.length} level-link cells for the link to ${o.level}`)
} else if (cellsOnLink.length === 1) {
const c = cellsOnLink[0]!
from = (horizontal ? c.gx : c.gy) * TILES_PER_GRID_CELL
to = from + TILES_PER_GRID_CELL
} else {
// Shared edge of the two rectangles, in level-local tiles along the side.
const lo = horizontal ? Math.max(coord.x, o.box.x) : Math.max(coord.y, o.box.y)
const hi = horizontal
? Math.min(coord.x + coord.width, o.box.x + o.box.width)
: Math.min(coord.y + coord.height, o.box.y + o.box.height)
if (hi <= lo) throw new Error(`level ${levelId}: the link to ${o.level} (${side}) shares no edge`)
from = lo - (horizontal ? coord.x : coord.y)
to = hi - (horizontal ? coord.x : coord.y)
}
const mid = Math.floor((from + to - 1) / 2)
const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid
const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid
const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid
const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid
out.push({
x: edgeX,
y: edgeY,
interiorX: inX,
interiorY: inY,
toLevelId: o.level,
side,
kind: 'gate',
span: { from, to },
})
}
return out
}
/** Which tile array a {@link DumpTileRef} names, as the adapter's tile kind. */
const TILE_KIND_OF_ARRAY = { w: 'wall', f: 'floor', r: 'shadow' } as const
/**
* The adapter input for one level of a schema-3 DRLG dump.
*
* The native oracle's JSON and the port's `dumpAct1` have the same shape, so both go through the same
* mapping and a comparison of the two compares like with like. The dump must come from an act run with
* activation: every room of the level activated, `mapTiles` being the final state after all of them.
*
* - tiles: each room's own tile data, i.e. the entries DRLGROOMTILE_InitWallTileData /
* InitFloorTileData / InitShadowTileData filled (`pTileGrid->nWalls/nFloors/nShadows` of the
* `pTiles` arrays; the rest of each allocation stays zeroed and is never drawn), then the tiles the
* room's pMapLinks point at (DRLGROOMTILE_LinkedTileDataManager's merge of shared room edges, which
* reaches rooms of the neighbouring level across a seam).
* - units: `mapTiles.units`, the room's pPresetUnits after DRLGROOMTILE_AddRoomMapTiles (so with the
* UNIT_TILE units of DRLGROOMTILE_AddWarp), moved from room-relative to level-local sub-tiles.
* - warps: the room's pRoomTiles chain.
*
* @param dump - schema-3 dump of an act run with activation.
* @param levelId - the level to map.
* @param tables - the DRLG tables the dump was made with (for LvlWarp.txt rows).
* @returns the adapter input.
*/
export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: DrlgTables): DrlgLevelInput {
if (dump.schema !== 3) throw new Error(`DRLG dump schema ${String(dump.schema)}, expected 3`)
const act = dump.act.find(a => a.id === levelId)
if (act === undefined) throw new Error(`level ${levelId}: not in the dump's act section`)
const levelsById = new Map<number, DumpLevel>(dump.levels.map(l => [l.id, l]))
const level = levelsById.get(levelId)
if (level === undefined) throw new Error(`level ${levelId}: not generated in the dump`)
const levelGrid = level.levelGrid
if (!levelGrid) throw new Error(`level ${levelId}: the dump has no levelGrid stage (not an outdoor level)`)
const grid2 = levelGrid.grids[2]
if (!grid2) throw new Error(`level ${levelId}: pGrid[2] was never allocated`)
if (level.activation.length !== level.rooms.length) {
throw new Error(`level ${levelId}: ${level.activation.length} of ${level.rooms.length} rooms activated (the dump must be made with activation)`)
}
const [levelX, levelY, levelW, levelH] = act.coord
// pOutdoors->pCoord is a union over nWidth, nHeight, nGridWidth, nGridHeight (8-tile grid cells).
const [, , gridW, gridH] = levelGrid.coord
if (gridW * TILES_PER_GRID_CELL !== levelW || gridH * TILES_PER_GRID_CELL !== levelH) {
throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`)
}
const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => ({
key,
kind,
x: room[0] + t[2] - levelX,
y: room[1] + t[3] - levelY,
flags: t[5] >>> 0,
tileType: t[7],
tile: t[6] === null ? null : { library: normalizeDrlgPath(t[6][0]), index: t[6][1] },
})
const initialised = (level: DumpLevel, room: number, array: 'w' | 'f' | 'r'): { list: readonly DumpTile[]; count: number } => {
const mt = level.activation[room]!.mapTiles
const [list, count] = array === 'w' ? [mt.walls, mt.nWalls] : array === 'f' ? [mt.floors, mt.nFloors] : [mt.roofs, mt.nShadows]
if (count < 0 || count > list.length) {
throw new Error(`level ${level.id} room ${room}: ${count} initialised ${TILE_KIND_OF_ARRAY[array]} tiles but ${list.length} allocated`)
}
return { list, count }
}
const tiles: DrlgTileInput[] = []
const units: DrlgUnitInput[] = []
const warps: DrlgWarpInput[] = []
level.rooms.forEach((room, r) => {
const what = `level ${levelId} room ${r}`
const mt = level.activation[r]!.mapTiles
for (const array of ['w', 'f', 'r'] as const) {
const { list, count } = initialised(level, r, array)
for (let i = 0; i < count; i += 1) {
tiles.push(place(`${levelId}/${r}/${array}/${i}`, TILE_KIND_OF_ARRAY[array], room.coord, list[i]!))
}
}
for (const [, ref] of mt.links) {
if (ref === null) throw new Error(`${what}: a map link without a tile`)
const [refLevel, refRoom, array, index] = ref
const owner = levelsById.get(refLevel)
const ownerRoom = owner?.rooms[refRoom]
if (owner === undefined || ownerRoom === undefined || owner.activation[refRoom] === undefined) {
throw new Error(`${what}: linked tile ${ref.join('/')} is in a room the dump did not activate`)
}
const { list, count } = initialised(owner, refRoom, array)
if (index < 0 || index >= count) {
throw new Error(`${what}: linked tile ${ref.join('/')} is not one of the owner's ${count} initialised tiles`)
}
tiles.push(place(ref.join('/'), TILE_KIND_OF_ARRAY[array], ownerRoom.coord, list[index]!))
}
const subDX = SUBTILES_PER_TILE * (room.coord[0] - levelX)
const subDY = SUBTILES_PER_TILE * (room.coord[1] - levelY)
for (const [type, index, mode, x, y, spawned] of mt.units) {
units.push({ room: r, type, index, mode, subX: x + subDX, subY: y + subDY, spawned })
}
for (const [destination, row] of mt.warps) {
if (destination === null) throw new Error(`${what}: a warp without a destination room`)
const record = tables.lvlWarp[row]
if (record === undefined) throw new Error(`${what}: warp to level ${destination[0]} has LvlWarp.txt row ${row}`)
warps.push({ room: r, lvlWarpId: record.dwLevelId, toLevelId: destination[0] })
}
})
const orths: DrlgOrthInput[] = levelGrid.roomData.map(o => {
if (o.level < 0 || o.box === null) throw new Error(`level ${levelId}: an outdoor link without a neighbour level`)
return { level: o.level, dir: o.dir, box: { x: o.box[0], y: o.box[1], width: o.box[2], height: o.box[3] } }
})
return {
levelId,
drlgType: act.drlgType,
levelType: act.levelType,
coord: { x: levelX, y: levelY, width: levelW, height: levelH },
flags: levelGrid.flags >>> 0,
orths,
grid2,
tiles,
units,
warps,
}
}

View File

@ -1,4 +1,3 @@
// @ts-nocheck
/**
* D2Common outdoor DRLG: 1:1 port of D2MOO source/D2Common/src/Drlg/DrlgOutdoors.cpp and the inline
* helper of include/Drlg/D2DrlgOutdoors.h (commit 5596f5c, MIT License, Copyright (c) 2020-2025

View File

@ -0,0 +1,38 @@
/**
* D2Common outdoor rooms: 1:1 port of DRLGOUTROOM_InitializeDrlgOutdoorRoom from D2MOO
* source/D2Common/src/Drlg/DrlgOutRoom.cpp (commit 5596f5c, MIT License, Copyright (c) 2020-2025
* The Phrozen Keep community). The allocation of D2DrlgOutdoorRoomStrc and the level-link callbacks of
* that file are ported with the outdoor generator (drlg-outdoors.ts / drlg-outplace.ts).
*/
import { DRLGLOGIC_AllocCoordLists } from './drlg-logic.ts'
import {
DRLGROOMTILE_AllocTileData,
DRLGROOMTILE_AllocTileGrid,
DRLGROOMTILE_CountAllTileTypes,
DRLGROOMTILE_CountWallWarpTiles,
DRLGROOMTILE_LoadInitRoomTiles,
tileGridOf,
} from './drlg-roomtile.ts'
import type { D2DrlgRoomStrc } from './drlg-types.ts'
//D2Common.6FD83E20
export function DRLGOUTROOM_InitializeDrlgOutdoorRoom(pDrlgRoom: D2DrlgRoomStrc): void {
const pOutdoor = pDrlgRoom.pOutdoor
if (!pOutdoor) throw new Error('DRLGOUTROOM_InitializeDrlgOutdoorRoom: pDrlgRoom->pOutdoor is NULL')
DRLGROOMTILE_AllocTileGrid(pDrlgRoom)
DRLGROOMTILE_CountWallWarpTiles(pDrlgRoom, pOutdoor.pWallGrid, pOutdoor.pTileTypeGrid, false, false)
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pOutdoor.pWallGrid, false, false, false)
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pOutdoor.pFloorGrid, false, false, false)
DRLGROOMTILE_AllocTileData(pDrlgRoom)
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pOutdoor.pWallGrid, pOutdoor.pTileTypeGrid, false, false, false)
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pOutdoor.pFloorGrid, null, false, false, false)
const pTileGrid = tileGridOf(pDrlgRoom)
pTileGrid.pTiles.nWalls = pTileGrid.nWalls
pTileGrid.pTiles.nFloors = pTileGrid.nFloors
pTileGrid.pTiles.nRoofs = pTileGrid.nShadows
DRLGLOGIC_AllocCoordLists(pDrlgRoom)
}

View File

@ -22,6 +22,7 @@ import {
gObjPresetToObjectId,
gTileTypeMappingTable,
LVLPREST_ACT1_BRIDGE,
LVLPREST_ACT1_GRAVEYARD,
LVLPREST_ACT1_RIVER_LOWER,
LVLPREST_ACT1_RIVER_UPPER,
LVLPREST_ACT1_TOWN_1,
@ -46,10 +47,17 @@ import {
DRLG_MAX_FLOOR_LAYERS,
DRLG_MAX_WALL_LAYERS,
FLAG_OPERATION_OR,
IMODE_ONGROUND,
LEVEL_BLOODMOOR,
LEVEL_BURIALGROUNDS,
LEVEL_HARROGATH,
LEVEL_LUTGHOLEIN,
LEVEL_THEPANDEMONIUMFORTRESS,
MONMODE_NEUTRAL,
OBJMODE_NEUTRAL,
UNIT_ITEM,
UNIT_MONSTER,
UNIT_OBJECT,
newCoord,
newGrid,
newPresetRoom,
@ -83,19 +91,30 @@ import {
DRLGROOM_GetVisArrayFromLevelId,
} from './drlg-room.ts'
import { DRLGWARP_GetWarpDestinationFromArray } from './drlg-warp.ts'
// Used only inside DRLGPRESET_AddPresetRoomMapTiles (at run time), so these import cycles are harmless.
import { DRLGLOGIC_AllocCoordLists, DRLGLOGIC_InitializeDrlgCoordList } from './drlg-logic.ts'
import {
DRLGROOMTILE_AllocTileData,
DRLGROOMTILE_AllocTileGrid,
DRLGROOMTILE_CountAllTileTypes,
DRLGROOMTILE_CountWallWarpTiles,
DRLGROOMTILE_LoadInitRoomTiles,
PackedTile,
tileGridOf,
} from './drlg-roomtile.ts'
// D2C_UnitTypes (Units/Units.h)
export const UNIT_PLAYER = 0
export const UNIT_MONSTER = 1
export const UNIT_OBJECT = 2
export const UNIT_MISSILE = 3
export const UNIT_ITEM = 4
export const UNIT_TILE = 5
// D2C_MonModes / D2C_ObjectModes / D2C_ItemModes
export const MONMODE_NEUTRAL = 1
export const OBJMODE_NEUTRAL = 0
export const IMODE_ONGROUND = 3
// D2C_UnitTypes and unit modes: defined in the leaf module drlg-types.ts (see the note there).
export {
IMODE_ONGROUND,
MONMODE_NEUTRAL,
OBJMODE_NEUTRAL,
UNIT_ITEM,
UNIT_MISSILE,
UNIT_MONSTER,
UNIT_OBJECT,
UNIT_PLAYER,
UNIT_TILE,
} from './drlg-types.ts'
// D2C_MonsterIds / D2C_SuperUniques (DataTbls/MonsterIds.h)
export const MONSTER_ACT2VENDOR1 = 204
@ -1123,3 +1142,154 @@ export function DRLGPRESET_InitPresetRoomGrids(pDrlgRoom: D2DrlgRoomStrc): void
}
}
// DrlgDrlgAnim.cpp is not ported: no Act I LvlPrest.txt row sets Animate (the call sites throw instead).
function DRLGANIM_NotPorted(szFunction: string): never {
throw new Error(`${szFunction} is not ported (LvlPrest.txt Animate is set)`)
}
//D2Common.0x6FD87130
export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): void {
DRLGROOMTILE_AllocTileGrid(pDrlgRoom)
const pMaze = pDrlgRoom.pMaze!
const pMazeMap = pDrlgRoom.pMaze!.pMap!
const pFile = pMazeMap.pFile!
let bKillEdgeX = false
let bKillEdgeY = false
if (pMazeMap.pLvlPrestTxtRecord.dwKillEdge) {
bKillEdgeX =
((pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) | 0) ===
((pMazeMap.pDrlgCoord.nPosX + pMazeMap.pDrlgCoord.nWidth) | 0)
bKillEdgeY =
((pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) | 0) ===
((pMazeMap.pDrlgCoord.nPosY + pMazeMap.pDrlgCoord.nHeight) | 0)
}
const tTownOrGraveyardTileInfoGrid = newGrid()
const aCellOffsets = new Int32Array(256)
const aCellPosBuffer = new Int32Array(1024)
const tTownOrGraveyardTileTypeGrid = newGrid()
const aTownOrGraveyardCellPosOffsets = new Int32Array(256)
const aTownOrGraveyardCellPosBuffer = new Int32Array(1024)
const bTownOrGraveyard =
pDrlgRoom.pMaze!.nLevelPrest === LVLPREST_ACT1_TOWN_1 || pDrlgRoom.pMaze!.nLevelPrest === LVLPREST_ACT1_GRAVEYARD
if (bTownOrGraveyard) {
DRLGGRID_FillGrid(
tTownOrGraveyardTileTypeGrid,
(pDrlgRoom.pDrlgCoord.nWidth + 1) | 0,
(pDrlgRoom.pDrlgCoord.nHeight + 1) | 0,
aTownOrGraveyardCellPosBuffer,
0,
aTownOrGraveyardCellPosOffsets,
)
DRLGGRID_FillGrid(
tTownOrGraveyardTileInfoGrid,
(pDrlgRoom.pDrlgCoord.nWidth + 1) | 0,
(pDrlgRoom.pDrlgCoord.nHeight + 1) | 0,
aCellPosBuffer,
0,
aCellOffsets,
)
}
for (let i = 0; i < pFile.nFloorLayers; i += 1) {
const bCheckCoordinatesValidity = i === 0 && pMazeMap.pLvlPrestTxtRecord.dwFillBlanks !== 0
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pFloorGrid[i]!, bCheckCoordinatesValidity, bKillEdgeX, bKillEdgeY)
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
}
}
for (let i = 0; i < pFile.nWallLayers; i += 1) {
DRLGROOMTILE_CountWallWarpTiles(pDrlgRoom, pMaze.pWallGrid[i]!, pMaze.pTileTypeGrid[i]!, bKillEdgeX, bKillEdgeY)
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pWallGrid[i]!, false, bKillEdgeX, bKillEdgeY)
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
}
}
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pCellGrid, false, bKillEdgeX, bKillEdgeY)
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
}
if (bTownOrGraveyard) {
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, tTownOrGraveyardTileInfoGrid, false, bKillEdgeX, bKillEdgeY)
}
DRLGROOMTILE_AllocTileData(pDrlgRoom)
for (let i = 0; i < pFile.nFloorLayers; i += 1) {
const bCheckCoordinatesValidity = i === 0 && pMazeMap.pLvlPrestTxtRecord.dwFillBlanks !== 0
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pMaze.pFloorGrid[i]!, null, bCheckCoordinatesValidity, bKillEdgeX, bKillEdgeY)
}
for (let i = 0; i < pFile.nWallLayers; i += 1) {
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pMaze.pWallGrid[i]!, pMaze.pTileTypeGrid[i]!, false, bKillEdgeX, bKillEdgeY)
}
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, pMaze.pCellGrid, null, false, bKillEdgeX, bKillEdgeY)
if (bTownOrGraveyard) {
DRLGROOMTILE_LoadInitRoomTiles(pDrlgRoom, tTownOrGraveyardTileInfoGrid, tTownOrGraveyardTileTypeGrid, false, bKillEdgeX, bKillEdgeY)
}
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_AllocAnimationTileGrids')
}
const pTileGrid = tileGridOf(pDrlgRoom)
pTileGrid.pTiles.nWalls = pTileGrid.nWalls
pTileGrid.pTiles.nFloors = pTileGrid.nFloors
pTileGrid.pTiles.nRoofs = pTileGrid.nShadows
if (pMazeMap.pLvlPrestTxtRecord.dwLogicals) {
// C passes the first element of each grid array.
DRLGLOGIC_InitializeDrlgCoordList(pDrlgRoom, pMaze.pTileTypeGrid[0]!, pMaze.pFloorGrid[0]!, pMaze.pWallGrid[0]!)
} else {
DRLGLOGIC_AllocCoordLists(pDrlgRoom)
}
if (bTownOrGraveyard) {
DRLGGRID_ResetGrid(tTownOrGraveyardTileTypeGrid)
DRLGGRID_ResetGrid(tTownOrGraveyardTileInfoGrid)
}
if (pDrlgRoom.pLevel.nLevelId === LEVEL_BURIALGROUNDS && !pMaze.pTombStoneTiles) {
let nTombStoneTiles = 0
// D2_ALLOC_POOL(6 * sizeof(D2CoordStrc)): zero-filled like the oracle's FOG_AllocPool (calloc).
const pTombStoneTiles = [0, 1, 2, 3, 4, 5].map(() => ({ nX: 0, nY: 0 }))
pMaze.pTombStoneTiles = pTombStoneTiles
for (let i = 0; i < pDrlgRoom.pDrlgCoord.nHeight; i += 1) {
for (let j = 0; j < pDrlgRoom.pDrlgCoord.nWidth; j += 1) {
const nPackedTileInformation = DRLGGRID_GetGridEntry(pMaze.pWallGrid[0]!, j, i) >>> 0
if (
PackedTile.nTileStyle(nPackedTileInformation) === 10 &&
PackedTile.nTileSequence(nPackedTileInformation) >= 23 &&
PackedTile.nTileSequence(nPackedTileInformation) <= 27
) {
if (nTombStoneTiles < 6) {
const nX = (j + pDrlgRoom.pDrlgCoord.nPosX) | 0
const nY = (i + pDrlgRoom.pDrlgCoord.nPosY) | 0
const tSubtile = DUNGEON_GameTileToSubtileCoords(nX, nY)
pTombStoneTiles[nTombStoneTiles]!.nX = (tSubtile.nX + 2) | 0
pTombStoneTiles[nTombStoneTiles]!.nY = (tSubtile.nY + 2) | 0
nTombStoneTiles += 1
}
}
}
}
pMaze.nTombStoneTiles = nTombStoneTiles
}
}

View File

@ -25,9 +25,10 @@ import {
type D2DrlgRoomStrc,
type D2DrlgStrc,
type D2PresetUnitStrc,
type D2RoomTileStrc,
} from './drlg-types.ts'
import { DRLGPRESET_AllocPresetRoomData } from './drlg-preset.ts'
import { DRLGWARP_GetWarpDestinationFromArray } from './drlg-warp.ts'
import { DRLGWARP_GetLvlWarpTxtRecordFromWarpIdAndDirection, DRLGWARP_GetWarpDestinationFromArray } from './drlg-warp.ts'
//D2Common.0x6FD771C0
// fRoomStatus = 4 is not modelled (room status lists only matter to the activation scheduler).
@ -42,12 +43,16 @@ export function DRLGROOM_AllocRoomEx(pLevel: D2DrlgLevelStrc, nType: number): D2
pOutdoor: null,
dwDT1Mask: 0,
pTiles: [],
pTileGrid: null,
nRoomsNear: 0,
ppRoomsNear: [],
pRoomTiles: null,
pPresetUnits: null,
pDrlgOrth: null,
pSeed: newD2Seed(),
dwInitSeed: 0,
pLogicalRoomInfo: null,
pRoom: null,
pDrlgRoomNext: null,
}
@ -357,7 +362,7 @@ export function DRLGROOM_SortRoomListByPosition(ppRoomList: D2DrlgRoomStrc[], nL
//D2Common.0x6FD77F00
export function sub_6FD77F00(
pDrlgRoom1: D2DrlgRoomStrc,
_nWarpId: number,
nWarpId: number,
pDrlgRoom2: D2DrlgRoomStrc | null,
nWarpFlag: number,
nDirection: number,
@ -390,6 +395,18 @@ export function sub_6FD77F00(
pDrlgRoom1.ppRoomsNear.push(pDrlgRoom2)
pDrlgRoom1.nRoomsNear += 1
DRLGROOM_SortRoomListByPosition(pDrlgRoom1.ppRoomsNear, pDrlgRoom1.nRoomsNear)
// D2_CALLOC_STRC_POOL(D2RoomTileStrc)
const pRoomTile: D2RoomTileStrc = {
pDrlgRoom: pDrlgRoom2,
pLvlWarpTxtRecord: DRLGWARP_GetLvlWarpTxtRecordFromWarpIdAndDirection(pDrlgRoom1.pLevel, nWarpId, 'b'),
bEnabled: 1,
unk0x0C: null,
unk0x10: null,
pNext: pDrlgRoom1.pRoomTiles,
}
pDrlgRoom1.pRoomTiles = pRoomTile
return true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -478,3 +478,28 @@ export function DATATBLS_GetLvlTypesTxtRecord(t: DrlgTables, nLevelType: number)
return rec
}
//1.10f:D2Common.0x6FD61DE0
//1.11: D2Common.0x6FD86840
// LevelsTbls.cpp:747. `szDirection` is the C char ('b', 'l', 'r'); records compare their szDirection[0].
export function DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection(
t: DrlgTables,
nLevelId: number,
szDirection: string,
): LvlWarpRecord {
if (t.lvlWarp.length === 0) throw new Error('D2_ASSERT(sgptDataTables->pLvlWarpTxt)')
for (let i = 0; i < t.lvlWarp.length; i += 1) {
const rec = t.lvlWarp[i]!
// uint32_t dwLevelId == int nLevelId: equal bit patterns.
if ((rec.dwLevelId | 0) === (nLevelId | 0)) {
if (szDirection === 'b' || rec.szDirection[0] === 'b' || rec.szDirection[0] === szDirection) {
return rec
}
}
}
throw new Error(
`DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection(${nLevelId}, '${szDirection}'): Error in DRLG related to stairs that connect levels`,
)
}

View File

@ -17,8 +17,8 @@
*/
import type { D2SeedStrc } from '../d2-rng.ts'
import type { D2TileLibraryStrc } from './drlg-d2cmp.ts'
import type { DrlgDataSource, DrlgTables, LvlMazeRecord, LvlPrestRecord } from './drlg-tables.ts'
import type { D2TileLibraryEntryStrc, D2TileLibraryStrc } from './drlg-d2cmp.ts'
import type { DrlgDataSource, DrlgTables, LvlMazeRecord, LvlPrestRecord, LvlWarpRecord } from './drlg-tables.ts'
// ---------------------------------------------------------------------------------------------
// Enums (D2DrlgDrlg.h, D2DrlgOutdoors.h, DataTbls/LevelsIds.h, D2CommonDefinitions.h)
@ -50,6 +50,20 @@ export const DRLGTYPE_MAZE = 1
export const DRLGTYPE_PRESET = 2
export const DRLGTYPE_OUTDOOR = 3
// D2C_UnitTypes (Units/Units.h). Kept in this leaf module: drlg-preset.ts and drlg-roomtile.ts
// import each other, and drlg-roomtile.ts builds a module-level table (stru_6FDD0F68) from them.
export const UNIT_PLAYER = 0
export const UNIT_MONSTER = 1
export const UNIT_OBJECT = 2
export const UNIT_MISSILE = 3
export const UNIT_ITEM = 4
export const UNIT_TILE = 5
// D2C_MonModes / D2C_ObjectModes / D2C_ItemModes
export const MONMODE_NEUTRAL = 1
export const OBJMODE_NEUTRAL = 0
export const IMODE_ONGROUND = 3
export const DRLGSUBST_NONE = 0
export const DRLGSUBST_FIXED = 1
export const DRLGSUBST_RANDOM = 2
@ -347,6 +361,9 @@ export interface D2DrlgPresetRoomStrc {
pFloorGrid: D2DrlgGridStrc[]
pCellGrid: D2DrlgGridStrc
pMazeGrid: D2DrlgGridStrc | null
/** D2_ALLOC_POOL(6 * sizeof(D2CoordStrc)) by DRLGPRESET_AddPresetRoomMapTiles (LEVEL_BURIALGROUNDS). */
pTombStoneTiles: D2CoordStrc[] | null
nTombStoneTiles: number
}
/** D2DrlgOutdoorRoomStrc */
@ -365,6 +382,146 @@ export interface D2DrlgOutdoorRoomStrc {
nSubThemePicked: number
}
/** D2CoordStrc */
export interface D2CoordStrc {
nX: number
nY: number
}
/** D2RoomCoordListStrc (D2DrlgDrlg.h:260). */
export interface D2RoomCoordListStrc {
/** Embedded array of 2. */
pBox: [D2DrlgCoordStrc, D2DrlgCoordStrc]
bNode: number
bRoomActive: number
nIndex: number
pNext: D2RoomCoordListStrc | null
}
/**
* D2DrlgTileDataStrc (D2DrlgDrlg.h:333). Tile data live in the arrays of D2DrlgRoomTilesStrc; a
* `D2DrlgTileDataStrc*` is a reference to an element of such an array.
*/
export interface D2DrlgTileDataStrc {
/** 0x00: client X of the tile (DUNGEON_GameTileToClientCoords). */
nWidth: number
/** 0x04: client Y of the tile bottom. */
nHeight: number
/** 0x08: tile X relative to the room. */
nPosX: number
/** 0x0C: tile Y relative to the room. */
nPosY: number
/**
* 0x10 (int32): DRLGLOGIC_SetCoordListForTiles stores the `D2RoomCoordListStrc*` of the tile's
* logical room here (C casts the pointer to int); otherwise 0.
*/
unk0x10: number | D2RoomCoordListStrc
/** 0x14: uint32_t D2MapTileFlags */
dwFlags: number
pTile: D2TileLibraryEntryStrc | null
nTileType: number
/** 0x20: next tile of the same chain (link list, warp list, shadow list). */
unk0x20: D2DrlgTileDataStrc | null
unk0x24: number
/** uint8_t */
nRed: number
/** uint8_t */
nGreen: number
/** uint8_t */
nBlue: number
/** uint8_t */
nIntensity: number
unk0x2C: number
}
/**
* D2DrlgRoomTilesStrc. Each array's length is its allocation size in elements
* (D2_CALLOC_POOL / D2_REALLOC_POOL); the port throws where C would write past it.
*/
export interface D2DrlgRoomTilesStrc {
pWallTiles: D2DrlgTileDataStrc[] | null
nWalls: number
pFloorTiles: D2DrlgTileDataStrc[] | null
nFloors: number
/** Shadows occupy pRoofTiles[0..nShadows) (DRLGROOMTILE_InitShadowTileData). */
pRoofTiles: D2DrlgTileDataStrc[] | null
nRoofs: number
}
/** D2DrlgTileLinkStrc */
export interface D2DrlgTileLinkStrc {
bFloor: number
pMapTile: D2DrlgTileDataStrc | null
pNext: D2DrlgTileLinkStrc | null
}
/** D2DrlgAnimTileGridStrc */
export interface D2DrlgAnimTileGridStrc {
ppMapTileData: (D2DrlgTileDataStrc | null)[]
nFrames: number
nCurrentFrame: number
nAnimationSpeed: number
pNext: D2DrlgAnimTileGridStrc | null
}
/** D2DrlgTileGridStrc */
export interface D2DrlgTileGridStrc {
pMapLinks: D2DrlgTileLinkStrc | null
pAnimTiles: D2DrlgAnimTileGridStrc | null
nWalls: number
nFloors: number
nShadows: number
/** Embedded D2DrlgRoomTilesStrc. */
pTiles: D2DrlgRoomTilesStrc
}
/** D2RoomTileStrc: one warp of a room (allocated by sub_6FD77F00). */
export interface D2RoomTileStrc {
pDrlgRoom: D2DrlgRoomStrc
pLvlWarpTxtRecord: LvlWarpRecord
bEnabled: number
unk0x0C: D2DrlgTileDataStrc | null
unk0x10: D2DrlgTileDataStrc | null
pNext: D2RoomTileStrc | null
}
/** D2DrlgLogicalRoomInfoStrc (D2DrlgDrlgLogic.h, aka D2DrlgCoordListStrc). */
export interface D2DrlgLogicalRoomInfoStrc {
/** D2DrlgLogicalRoomInfoFlags */
dwFlags: number
nLists: number
pIndexX: D2DrlgGridStrc
/**
* C stores `(int)pRoomCoordList` in these cells (DRLGLOGIC_AssignCoordListsForGrids). Int32Array cells
* cannot hold objects: the port stores a 1-based handle into `ppCoordListHandles` (0 = NULL).
*/
pIndexY: D2DrlgGridStrc
pCoordList: D2RoomCoordListStrc | null
/** Port-only: the D2RoomCoordListStrc objects whose handles (index + 1) live in pIndexY cells. */
ppCoordListHandles: D2RoomCoordListStrc[]
}
/** A zeroed D2DrlgTileDataStrc (D2_CALLOC_POOL). */
export function newTileData(): D2DrlgTileDataStrc {
return {
nWidth: 0,
nHeight: 0,
nPosX: 0,
nPosY: 0,
unk0x10: 0,
dwFlags: 0,
pTile: null,
nTileType: 0,
unk0x20: null,
unk0x24: 0,
nRed: 0,
nGreen: 0,
nBlue: 0,
nIntensity: 0,
unk0x2C: 0,
}
}
/** D2DrlgRoomStrc (aka D2RoomExStrc). Tile coordinates live in `pDrlgCoord`. */
export interface D2DrlgRoomStrc {
pLevel: D2DrlgLevelStrc
@ -378,13 +535,24 @@ export interface D2DrlgRoomStrc {
/** uint32_t */
dwDT1Mask: number
pTiles: D2TileLibraryStrc[]
/** 0xA8: allocated by DRLGROOMTILE_AllocTileGrid. */
pTileGrid: D2DrlgTileGridStrc | null
nRoomsNear: number
ppRoomsNear: D2DrlgRoomStrc[]
/** 0xC8: warps of the room (sub_6FD77F00). */
pRoomTiles: D2RoomTileStrc | null
pPresetUnits: D2PresetUnitStrc | null
pDrlgOrth: D2DrlgOrthStrc | null
pSeed: D2SeedStrc
/** uint32_t */
dwInitSeed: number
/** 0xE0 */
pLogicalRoomInfo: D2DrlgLogicalRoomInfoStrc | null
/**
* 0xE4 D2ActiveRoomStrc*: set by DRLG_CreateRoomForRoomEx (D2Game side, not ported); always null
* in the port, like in the oracle.
*/
pRoom: null
pDrlgRoomNext: D2DrlgRoomStrc | null
}
@ -513,6 +681,8 @@ export function newPresetRoom(): D2DrlgPresetRoomStrc {
pFloorGrid: [newGrid(), newGrid()],
pCellGrid: newGrid(),
pMazeGrid: null,
pTombStoneTiles: null,
nTombStoneTiles: 0,
}
}

View File

@ -4,7 +4,11 @@
* outdoor generator.
*/
import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts'
import {
DATATBLS_GetLevelDefRecord,
DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection,
type LvlWarpRecord,
} from './drlg-tables.ts'
import type { D2DrlgLevelStrc, D2DrlgStrc } from './drlg-types.ts'
//D2Common.0x6FD78CC0
@ -22,3 +26,16 @@ export function DRLGWARP_GetWarpDestinationFromArray(pLevel: D2DrlgLevelStrc, nA
if (nIndex >= 8) throw new RangeError(`DRLGWARP_GetWarpDestinationFromArray: index ${nIndex}`)
return DRLGWARP_GetWarpIdArrayFromLevelId(pLevel.pDrlg, pLevel.nLevelId)[nIndex]!
}
//D2Common.0x6FD78D80 (DrlgDrlgWarp.cpp:304). nWarpId is a uint8_t parameter; szDirection the C char.
export function DRLGWARP_GetLvlWarpTxtRecordFromWarpIdAndDirection(
pLevel: D2DrlgLevelStrc,
nWarpId: number,
szDirection: string,
): LvlWarpRecord {
return DATATBLS_GetLvlWarpTxtRecordFromLevelIdAndDirection(
pLevel.pDrlg.env.tables,
DRLGWARP_GetWarpDestinationFromArray(pLevel, nWarpId & 0xff),
szDirection,
)
}

View File

@ -469,6 +469,9 @@ export interface BorderOpening {
* @param region - when given, only gaps inside it count as gaps.
* @param bandDepth - how many sub-tiles inwards to look before giving up.
* Defaults to half the level's depth on the axis being searched.
* @param range - when given, only this stretch of the edge is searched (sub-tiles along the side,
* `from` inclusive, `to` exclusive). Generated levels whose generator knows which part of the edge
* opens onto the neighbour pass it, so a gap elsewhere on the same edge is never taken for it.
* @returns the opening, or null if the edge is solid all the way in.
*/
export function findBorderOpening(
@ -476,11 +479,17 @@ export function findBorderOpening(
side: Side,
region?: WalkableRegion,
bandDepth?: number,
range?: { readonly from: number; readonly to: number },
): BorderOpening | null {
const horizontal = side === 'north' || side === 'south'
const span = horizontal ? grid.gridWidth : grid.gridHeight
const depth = horizontal ? grid.gridHeight : grid.gridWidth
const limit = Math.min(bandDepth ?? Math.floor(depth / 2), depth)
const lo = range?.from ?? 0
const hi = range?.to ?? span
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < 0 || hi > span || lo >= hi) {
throw new Error(`findBorderOpening: range ${String(lo)}..${String(hi)} is not inside the ${side} edge (0..${String(span)})`)
}
for (let inset = 0; inset < limit; inset += 1) {
const fixed = side === 'north' || side === 'west' ? inset : depth - 1 - inset
@ -488,8 +497,8 @@ export function findBorderOpening(
let bestStart = -1
let bestLength = 0
let runStart = -1
for (let along = 0; along <= span; along += 1) {
const open = along < span
for (let along = lo; along <= hi; along += 1) {
const open = along < hi
&& (horizontal ? inRegion(grid, region, along, fixed) : inRegion(grid, region, fixed, along))
if (open) {
if (runStart < 0) runStart = along

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,400 +0,0 @@
/**
* Authentic D2MOO v1.13c 4-Pass Secondary Border Evolution.
*
* Implements:
* - DRLGOUTDOORS_AddAct124SecondaryBorder (D2Common/src/Drlg/DrlgOutdoors.cpp:650)
* - DRLGTILESUB_AddSecondaryBorder (D2Common/src/Drlg/DrlgTileSub.cpp:18)
* - DRLGTILESUB_TestReplaceSubPreset (D2Common/src/Drlg/DrlgTileSub.cpp:148)
* - DRLGTILESUB_ReplaceSubPreset (D2Common/src/Drlg/DrlgTileSub.cpp:223)
*
* Ground Truth Passes:
* - Pass 1: LVLSUB_ACT1_BORDER_CLIFFS (BorderCliffs.ds1, dwBordType = 1)
* - Pass 2: LVLSUB_ACT1_BORDER_MIDDLE (BorderMiddle.ds1, dwBordType = 0, Bord9..12 interior 2x3 closures)
* - Pass 3: LVLSUB_ACT1_BORDER_CORNER (BorderCorner.ds1, dwBordType = 1, indented corner bays)
* - Pass 4: LVLSUB_ACT1_BORDER_BORDER (BorderBorder.ds1, dwBordType = 2, Bord5..8 outer peninsulas)
*/
import type { Ds1 } from "../../formats/ds1.ts"
import type { CanvasLike, MutableDs1Cell } from "./wilderness-river.ts"
import {
TILES_PER_BLOCK,
type D2OutdoorLevelContext,
} from "./wilderness-tables.ts"
import {
BORDER_CLIFFS_SUBST,
BORDER_MIDDLE_SUBST,
BORDER_CORNER_SUBST,
BORDER_BORDER_SUBST,
type DrlgSubstGroup,
} from "./wilderness-border-data.ts"
export interface BorderEvolutionOptions {
readonly context: D2OutdoorLevelContext
readonly borderPieces?: readonly { readonly name: string; readonly levels: readonly Ds1[] }[]
readonly suppressedBlocks: ReadonlySet<string>
readonly seed: number
}
export interface BorderEvolutionResult {
readonly placedBorderBlocks: Map<string, string> // "bx,by" -> ds1 name (e.g. "Bord9.ds1")
readonly notes: string[]
readonly peninsulaCount: number
readonly closureCount: number
}
/**
* 64-bit LCG PRNG matching D2Common SEED_RollLimitedRandomNumber.
* lo = lo * 0x6AC690C5 + hi
*/
class D2BorderRng {
private lo: number
private hi: number
constructor(seed: number, factor = 666) {
this.lo = seed >>> 0
this.hi = factor >>> 0
}
rand(max: number): number {
if (max <= 0) return 0
const next = (BigInt(this.lo) * 0x6ac690c5n + BigInt(this.hi)) & 0xffffffffffffffffn
this.lo = Number(next & 0xffffffffn) >>> 0
this.hi = Number(next >> 32n) >>> 0
return Number(BigInt(this.lo) % BigInt(max))
}
}
/**
* Executes the authentic D2MOO 4-pass secondary border evolution pipeline.
*/
export function DRLGOUTDOORS_AddAct124SecondaryBorder(
canvas: CanvasLike,
options: BorderEvolutionOptions,
): BorderEvolutionResult {
const { context, borderPieces, suppressedBlocks, seed } = options
const { gridWidth, gridHeight, dwFlags } = context
const rng = new D2BorderRng(seed)
// 1. Initialize Outdoor Block Grid (pGrid[0]):
// 0 = open floor, 1..12 = Bord1..Bord12, -1 = blank/outside.
const blockGrid: number[][] = Array.from({ length: gridHeight }, () =>
Array(gridWidth).fill(0),
)
// Initial perimeter borders per DRLGOUTPLACE_PlaceAct1245OutdoorBorders:
for (let x = 0; x < gridWidth; x += 1) {
blockGrid[0][x] = 3 // North edge: Bord3 (South-facing wall)
blockGrid[gridHeight - 1][x] = 1 // South edge: Bord1 (North-facing wall)
}
for (let y = 0; y < gridHeight; y += 1) {
blockGrid[y][0] = 2 // West edge: Bord2 (East-facing wall)
blockGrid[y][gridWidth - 1] = 4 // East edge: Bord4 (West-facing wall)
}
// Corners
blockGrid[0][0] = 6 // NW: Bord6
blockGrid[0][gridWidth - 1] = 7 // NE: Bord7
blockGrid[gridHeight - 1][0] = 5 // SW: Bord5
blockGrid[gridHeight - 1][gridWidth - 1] = 8 // SE: Bord8
// Suppressed blocks (river columns, town gates) are cleared from default border
for (const key of suppressedBlocks) {
const [bxStr, byStr] = key.split(",")
const bx = Number(bxStr)
const by = Number(byStr)
if (bx >= 0 && bx < gridWidth && by >= 0 && by < gridHeight) {
blockGrid[by][bx] = -1 // marked as occupied / external
}
}
const placedBorderBlocks = new Map<string, string>()
const notes: string[] = []
let peninsulaCount = 0
let closureCount = 0
// Helper to find piece DS1 by filename or preset index
const findPiece = (bordIndex: number): Ds1 | undefined => {
if (!borderPieces) return undefined
const pattern = new RegExp(`Border\\s*0?${bordIndex}\\b|Bord${bordIndex}\\.ds1`, "i")
for (const p of borderPieces) {
if (pattern.test(p.name)) {
return p.levels[0]
}
}
return undefined
}
/**
* Core pattern matching and replacement engine: DRLGTILESUB_AddSecondaryBorder.
*/
const executeSubstPass = (
passName: string,
groups: readonly DrlgSubstGroup[],
bordType: number,
nLvlSubId: number,
): number => {
if (groups.length === 0) return 0
let nRand = 0
if (bordType === 0) {
// Pass 2 (BorderMiddle): starts at random group
nRand = rng.rand(groups.length)
}
let passPlacedCount = 0
const nOffset = nLvlSubId === 1 && (dwFlags & 12) !== 0 ? -1 : 1
for (let j = 0; j < groups.length; j += 1) {
const groupIdx = (nRand + j) % groups.length
const group = groups[groupIdx]
const scanW = nOffset + gridWidth - group.width
const scanH = 1 + gridHeight - group.height
const nArea = scanW * scanH
if (nArea <= 0) continue
// Generate coordinates and pair-swap shuffle
const coords: Array<{ x: number; y: number }> = []
for (let i = 0; i < nArea; i += 1) {
coords.push({ x: i % scanW, y: Math.floor(i / scanW) })
}
for (let i = 0; i < nArea; i += 1) {
const r1 = rng.rand(nArea)
const r2 = rng.rand(nArea)
const tmp = coords[r1]
coords[r1] = coords[r2]
coords[r2] = tmp
}
const bSmallWilderness = nLvlSubId === 1 && gridWidth < 6 && gridHeight < 6
for (let i = 0; i < nArea; i += 1) {
const { x: nX, y: nY } = coords[i]
if (bSmallWilderness && nX === 2 && nY === 2) continue
// DRLGTILESUB_TestReplaceSubPreset: Check target requirement for all cells in group
let match = true
for (let gy = 0; gy < group.height; gy += 1) {
for (let gx = 0; gx < group.width; gx += 1) {
const req = group.target[gy * group.width + gx]
if (req === 0) continue // ignore cell
const targetX = nX + gx
const targetY = nY + gy
if (
targetX < 0 ||
targetX >= gridWidth ||
targetY < 0 ||
targetY >= gridHeight
) {
match = false
break
}
const current = blockGrid[targetY][targetX]
const key = `${targetX},${targetY}`
if (req === -1) {
// Open interior floor required: must be unoccupied and not suppressed
if (current !== 0 || suppressedBlocks.has(key)) {
match = false
break
}
} else if (req === 63) {
// Wildcard border required: must be an existing border (1..12) and not suppressed
if (current < 1 || current > 12 || suppressedBlocks.has(key)) {
match = false
break
}
} else if (req >= 1 && req <= 12) {
// Specific border required
if (current !== req || suppressedBlocks.has(key)) {
match = false
break
}
}
}
if (!match) break
}
if (match) {
// DRLGTILESUB_ReplaceSubPreset: Apply randomly picked variant
const vIdx = rng.rand(group.variants.length)
const variant = group.variants[vIdx]
for (let gy = 0; gy < group.height; gy += 1) {
for (let gx = 0; gx < group.width; gx += 1) {
const repl = variant[gy * group.width + gx]
const targetX = nX + gx
const targetY = nY + gy
const key = `${targetX},${targetY}`
if (repl >= 1 && repl <= 12) {
// Place border preset
blockGrid[targetY][targetX] = repl
const ds1Name = `Bord${repl}.ds1`
placedBorderBlocks.set(key, ds1Name)
passPlacedCount += 1
if (repl >= 9 && repl <= 12) closureCount += 1
if (repl >= 5 && repl <= 8) peninsulaCount += 1
// Stamp DS1 or placeholder tiles onto canvas
const ds1 = findPiece(repl)
if (ds1) {
stampDs1ToCanvas(
canvas,
ds1,
targetX * TILES_PER_BLOCK,
targetY * TILES_PER_BLOCK,
)
} else {
stampPlaceholderBorderBlock(
canvas,
targetX * TILES_PER_BLOCK,
targetY * TILES_PER_BLOCK,
)
}
} else if (repl === -1) {
// Clear to open floor
blockGrid[targetY][targetX] = 0
placedBorderBlocks.delete(key)
} else if (repl === -2) {
// Mark blank / exterior cell
blockGrid[targetY][targetX] = -1
placedBorderBlocks.delete(key)
}
}
}
if (bordType === 0) {
// BordType 0 (Middle): Terminate entire pass on first match!
return passPlacedCount
} else if (bordType === 1) {
// BordType 1 (Cliffs, Corner): Break out of coordinate loop for this group
break
}
// BordType 2 (Border): Continue scanning all coordinates in this group
}
}
}
return passPlacedCount
}
// --- PASS 1: Border Cliffs (LVLSUB_ACT1_BORDER_CLIFFS, BordType 1) ---
const cliffsCount = executeSubstPass(
"BorderCliffs",
BORDER_CLIFFS_SUBST,
1,
0,
)
notes.push(
`Secondary border Pass 1: BorderCliffs applied (${cliffsCount} blocks modified)`,
)
// --- PASS 2: Border Middle (LVLSUB_ACT1_BORDER_MIDDLE, BordType 0) ---
const middleCount = executeSubstPass(
"BorderMiddle",
BORDER_MIDDLE_SUBST,
0,
1,
)
notes.push(
`Secondary border Pass 2: Bord9..12 interior 2x3 forest closures stamped (${closureCount} blocks)`,
)
// --- PASS 3: Border Corner (LVLSUB_ACT1_BORDER_CORNER, BordType 1) ---
const cornerCount = executeSubstPass(
"BorderCorner",
BORDER_CORNER_SUBST,
1,
2,
)
notes.push(
`Secondary border Pass 3: BorderCorner applied (${cornerCount} corner bays modified)`,
)
// --- PASS 4: Border Border (LVLSUB_ACT1_BORDER_BORDER, BordType 2) ---
const borderCount = executeSubstPass(
"BorderBorder",
BORDER_BORDER_SUBST,
2,
3,
)
notes.push(
`Secondary border Pass 4: Bord5..8 outer peninsulas stamped (${peninsulaCount} blocks)`,
)
return {
placedBorderBlocks,
notes,
peninsulaCount,
closureCount,
}
}
function stampPlaceholderBorderBlock(
canvas: CanvasLike,
startX: number,
startY: number,
): void {
for (let ty = 0; ty < TILES_PER_BLOCK; ty += 1) {
for (let tx = 0; tx < TILES_PER_BLOCK; tx += 1) {
const cy = startY + ty
const cx = startX + tx
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const cell = canvas.cells[cy]?.[cx] as unknown as MutableDs1Cell | undefined
if (cell) {
// Tree wall tile (style 1, sequence 0, prop1 129)
cell.walls = [{ style: 1, sequence: 0, prop1: 129, hidden: false }]
}
}
}
}
}
function stampDs1ToCanvas(
canvas: CanvasLike,
ds1: Ds1,
originX: number,
originY: number,
): void {
for (let y = 0; y < ds1.height; y += 1) {
const cy = originY + y
if (cy >= canvas.height) break
const targetRow = canvas.cells[cy]
const sourceRow = ds1.cells[y]
if (!targetRow || !sourceRow) continue
for (let x = 0; x < ds1.width; x += 1) {
const cx = originX + x
if (cx >= canvas.width) break
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
const sourceCell = sourceRow[x]
if (!targetCell || !sourceCell) continue
for (let i = 0; i < sourceCell.floors.length; i += 1) {
const floor = sourceCell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
if (i < targetCell.floors.length) {
targetCell.floors[i] = { ...floor }
} else {
targetCell.floors.push({ ...floor })
}
}
}
for (let i = 0; i < sourceCell.walls.length; i += 1) {
const wall = sourceCell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
if (i < targetCell.walls.length) {
targetCell.walls[i] = { ...wall }
} else {
targetCell.walls.push({ ...wall })
}
}
}
}
}
}

View File

@ -1,165 +0,0 @@
/**
* Authentic D2MOO v1.13c base floor initialization & LvlSub SubType 6 cluster substitution.
*
* Implements:
* - Base floor 0x40002 (TileType 0, Style 0, Sequence 0 Grass) initialization (DRLGROOM_AllocRoomData)
* - LvlSub.txt SubType 6 cluster substitutions (Pud, Trees, Stone, Swamp, Object)
*/
import type { Ds1 } from '../../formats/ds1.ts'
import type { CanvasLike, MutableDs1Cell } from './wilderness-river.ts'
export interface ClusterSubstitutionOptions {
readonly widthTiles: number
readonly heightTiles: number
readonly subTheme: number
readonly substitutions?: readonly {
readonly name: string
readonly type: number
readonly prob: readonly number[]
readonly trials: readonly number[]
readonly levels: readonly Ds1[]
}[]
readonly occupiedTiles: ReadonlySet<string>
readonly seed: number
}
export interface ClusterSubstitutionResult {
readonly substitutedClusters: number
readonly substitutions: Array<{ name: string; role: 'object' | 'border'; enabled: boolean; clusters: number }>
}
/**
* Initializes all cells on canvas to uniform 0x40002 base grass floor.
*/
export function initializeBaseGrassFloor(canvas: CanvasLike): void {
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x] as unknown as MutableDs1Cell | undefined
if (cell) {
// Uniform 0x40002 base floor: style 0, sequence 0, prop1 = 2
cell.floors = [{ style: 0, sequence: 0, prop1: 2, hidden: false }]
}
}
}
}
/**
* Applies LvlSub SubType 6 cluster substitutions onto open grass cells.
*/
export function DRLGTLSUB_DoClusterSubstitutions(
canvas: CanvasLike,
options: ClusterSubstitutionOptions,
): ClusterSubstitutionResult {
const { widthTiles, heightTiles, subTheme, substitutions, occupiedTiles, seed } = options
const results: ClusterSubstitutionResult['substitutions'] = []
let totalClusters = 0
if (!substitutions || substitutions.length === 0) {
return { substitutedClusters: 0, substitutions: results }
}
// Filter SubType 6 substitutions
const clusterSubs = substitutions.filter(s => s.type === 6)
if (clusterSubs.length === 0) {
return { substitutedClusters: 0, substitutions: results }
}
const effectiveTheme = Math.max(0, Math.min(4, Math.floor(subTheme)))
let rngSeed = (seed ^ 0x5a5a5a5a) >>> 0
const nextRng = (): number => {
rngSeed = (Math.imul(rngSeed, 1103515245) + 12345) & 0x7fffffff
return rngSeed
}
for (const sub of clusterSubs) {
const prob = sub.prob[effectiveTheme] ?? 0
const trials = sub.trials[effectiveTheme] ?? 0
const ds1 = sub.levels[0]
let clusterCount = 0
if (trials > 0 && prob > 0 && ds1) {
for (let t = 0; t < trials; t += 1) {
if ((nextRng() % 100) < prob) {
// Find an unoccupied interior grass cell
const candX = 8 + (nextRng() % Math.max(1, widthTiles - 16 - ds1.width))
const candY = 8 + (nextRng() % Math.max(1, heightTiles - 16 - ds1.height))
let conflict = false
for (let dy = 0; dy < ds1.height; dy += 1) {
for (let dx = 0; dx < ds1.width; dx += 1) {
if (occupiedTiles.has(`${candX + dx},${candY + dy}`)) {
conflict = true
break
}
}
if (conflict) break
}
if (!conflict) {
stampSubDs1ToCanvas(canvas, ds1, candX, candY)
clusterCount += 1
totalClusters += 1
}
}
}
}
results.push({
name: sub.name,
role: 'object',
enabled: clusterCount > 0,
clusters: clusterCount,
})
}
return {
substitutedClusters: totalClusters,
substitutions: results,
}
}
function stampSubDs1ToCanvas(canvas: CanvasLike, ds1: Ds1, originX: number, originY: number): void {
for (let y = 0; y < ds1.height; y += 1) {
const cy = originY + y
if (cy >= canvas.height) break
const targetRow = canvas.cells[cy]
const sourceRow = ds1.cells[y]
if (!targetRow || !sourceRow) continue
for (let x = 0; x < ds1.width; x += 1) {
const cx = originX + x
if (cx >= canvas.width) break
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
const sourceCell = sourceRow[x]
if (!targetCell || !sourceCell) continue
for (let i = 0; i < sourceCell.floors.length; i += 1) {
const floor = sourceCell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
if (i < targetCell.floors.length) {
targetCell.floors[i] = { ...floor }
} else {
targetCell.floors.push({ ...floor })
}
}
}
for (let i = 0; i < sourceCell.walls.length; i += 1) {
const wall = sourceCell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
if (i < targetCell.walls.length) {
targetCell.walls[i] = { ...wall }
} else {
targetCell.walls.push({ ...wall })
}
}
}
}
}
}

View File

@ -1,469 +0,0 @@
/**
* Authentic D2MOO v1.13c town transition & landmark preset placement.
*
* Implements:
* - TownSTrans.ds1 (56x8 tiles / 7x1 blocks) edge-aligned gate placement (DrlgOutWild.cpp:379)
* - Complete unconditional landmark set for Blood Moor (DenEnt, Pond1, Cott1, Circle, Clover)
* - DRLGOUTDOORS_SpawnPresetFarAway 2:1 distance maximization metric (DrlgOutdoors.cpp:260)
* - Authentic 2D DRLG candidate pool with D2 PRNG Fisher-Yates coordinate shuffle (DrlgOutdoors.cpp:341)
* - Zero spatial overlap guarantee between all placed presets
*/
import type { Ds1 } from '../../formats/ds1.ts'
import type { CanvasLike, MutableDs1Cell } from './wilderness-river.ts'
import { D2Rng } from '../d2-rng.ts'
import {
TILES_PER_BLOCK,
OUTDOOR_SOUTHWEST,
OUTDOOR_NORTHWEST,
OUTDOOR_SOUTHEAST,
OUTDOOR_NORTHEAST,
type D2OutdoorLevelContext,
type D2LandmarkPreset,
} from './wilderness-tables.ts'
export interface LandmarkSpawnOptions {
readonly context: D2OutdoorLevelContext
readonly pieces?: readonly { readonly name: string; readonly levels: readonly Ds1[] }[]
readonly claimedBlocks: ReadonlySet<string>
readonly seed: number
}
export interface LandmarkSpawnResult {
readonly landmarks: D2LandmarkPreset[]
readonly notes: string[]
readonly townTransition?: {
readonly bx: number
readonly by: number
readonly widthTiles: number
readonly heightTiles: number
readonly name: string
} | undefined
}
interface Rect {
x: number
y: number
w: number
h: number
}
function rectsOverlap(a: Rect, b: Rect): boolean {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
)
}
function testBlockArea(
occupiedBlocks: ReadonlySet<string>,
gridWidth: number,
gridHeight: number,
bx: number,
by: number,
wBlocks: number,
hBlocks: number,
offset: number,
): boolean {
const xStart = bx - offset
const xEnd = bx + wBlocks + offset
const yStart = by - offset
const yEnd = by + hBlocks + offset
for (let y = yStart; y < yEnd; y += 1) {
for (let x = xStart; x < xEnd; x += 1) {
if (x < 0 || x >= gridWidth || y < 0 || y >= gridHeight) return false
if (occupiedBlocks.has(`${x},${y}`)) return false
}
}
return true
}
/**
* Places TownSTrans and all unconditional wilderness landmarks using authentic 2D DRLG candidate distribution.
*/
export function DRLGOUTWILD_AddPresets(
canvas: CanvasLike,
options: LandmarkSpawnOptions,
): LandmarkSpawnResult {
const { context, pieces, claimedBlocks, seed } = options
const { gridWidth, gridHeight, dwFlags, levelId } = context
const placedRects: Rect[] = []
const landmarks: D2LandmarkPreset[] = []
const notes: string[] = []
let townTransition: LandmarkSpawnResult['townTransition']
const occupiedBlocks = new Set<string>(claimedBlocks)
const d2Seed = new D2Rng(seed)
const findPiece = (pattern: RegExp): Ds1 | undefined => {
if (!pieces) return undefined
for (const p of pieces) {
if (pattern.test(p.name)) {
return p.levels[0]
}
}
return undefined
}
// 1. Town Transition (TownSTrans.ds1: 56x8 tiles = 7x1 blocks)
if (levelId === 2 || (dwFlags & (OUTDOOR_SOUTHWEST | OUTDOOR_NORTHWEST | OUTDOOR_SOUTHEAST | OUTDOOR_NORTHEAST))) {
let townBx = 0
let townBy = 0
const townW = 56
const townH = 8
if (dwFlags & OUTDOOR_NORTHWEST) {
// Anchored to river edge at eastern side: bx = gridWidth - 7, by = 0
townBx = Math.max(0, gridWidth - 7)
townBy = 0
} else if (dwFlags & OUTDOOR_SOUTHWEST) {
townBx = 0
townBy = 0
} else if (dwFlags & OUTDOOR_SOUTHEAST) {
townBx = 0
townBy = 1
} else if (dwFlags & OUTDOOR_NORTHEAST) {
townBx = 0
townBy = Math.max(0, gridHeight - 6)
} else {
townBx = Math.max(0, gridWidth - 7)
townBy = 0
}
const tileX = townBx * TILES_PER_BLOCK
const tileY = townBy * TILES_PER_BLOCK
townTransition = {
bx: townBx,
by: townBy,
widthTiles: townW,
heightTiles: townH,
name: 'TownSTrans.ds1',
}
placedRects.push({ x: tileX, y: tileY, w: townW, h: townH })
for (let bx = townBx; bx < townBx + 7; bx += 1) {
occupiedBlocks.add(`${bx},${townBy}`)
}
const townDs1 = findPiece(/Town.*STrans|Transition.*S/i) ?? findPiece(/Town.*Trans/i)
if (townDs1) {
stampDs1Safe(canvas, townDs1, tileX, tileY)
}
landmarks.push({
name: 'TownSTrans.ds1',
ds1Path: 'Act1/Town/TownSTrans.ds1',
gridX: townBx,
gridY: townBy,
tileX,
tileY,
widthTiles: townW,
heightTiles: townH,
roadExitTile: { x: tileX + 29, y: tileY + 8 },
})
notes.push(`Placed TownSTrans (Act 1 - Town 1 Transition) at (${tileX}, ${tileY})`)
}
// 2. Den of Evil (DenEnt.ds1, 16x16 tiles = 2x2 blocks) via FarAway metric
const townBaseX = townTransition ? townTransition.bx * TILES_PER_BLOCK + townTransition.widthTiles / 2 : 0
const townBaseY = townTransition ? townTransition.by * TILES_PER_BLOCK + townTransition.heightTiles / 2 : 0
const farAway = DRLGOUTDOORS_SpawnPresetFarAway({
gridWidth,
gridHeight,
occupiedBlocks,
placedRects,
baseX: townBaseX,
baseY: townBaseY,
seed: d2Seed,
presetWidthBlocks: 2,
presetHeightBlocks: 2,
offset: 1,
})
if (farAway) {
const denTileX = farAway.bx * TILES_PER_BLOCK
const denTileY = farAway.by * TILES_PER_BLOCK
placedRects.push({ x: denTileX, y: denTileY, w: 16, h: 16 })
for (let dx = 0; dx < 2; dx += 1) {
for (let dy = 0; dy < 2; dy += 1) {
occupiedBlocks.add(`${farAway.bx + dx},${farAway.by + dy}`)
}
}
const denDs1 = findPiece(/DOE|DenEnt/i)
if (denDs1) {
stampDs1Safe(canvas, denDs1, denTileX, denTileY)
}
landmarks.push({
name: 'DenEnt.ds1',
ds1Path: 'Act1/Outdoors/DenEnt.ds1',
gridX: farAway.bx,
gridY: farAway.by,
tileX: denTileX,
tileY: denTileY,
widthTiles: 16,
heightTiles: 16,
roadExitTile: { x: denTileX + 8, y: denTileY + 8 },
})
notes.push(`Placed DenEnt (Act 1 - DOE Entrance) at (${denTileX}, ${denTileY}) via FarAway metric`)
}
// Helper for authentic 2D DRLG candidate distribution with Fisher-Yates shuffle
const spawn2DLandmark = (
name: string,
ds1Path: string,
pattern: RegExp,
wBlocks: number,
hBlocks: number,
noteDesc: string,
offset = 0,
): boolean => {
const wTiles = wBlocks * TILES_PER_BLOCK
const hTiles = hBlocks * TILES_PER_BLOCK
const minBx = 1
const maxBx = Math.max(minBx, (context.hasRiver && context.riverColumn !== undefined ? context.riverColumn : gridWidth - 1) - wBlocks)
const minBy = 1
const maxBy = Math.max(minBy, gridHeight - 1 - hBlocks)
const spanX = maxBx - minBx + 1
const spanY = maxBy - minBy + 1
const nArea = spanX * spanY
let chosenBx = -1
let chosenBy = -1
if (nArea > 0) {
const pCoord: Array<{ x: number; y: number }> = []
for (let i = 0; i < nArea; i += 1) {
pCoord.push({ x: minBx + (i % spanX), y: minBy + Math.floor(i / spanX) })
}
// D2MOO Fisher-Yates coordinate shuffle
for (let i = 0; i < nArea; i += 1) {
const r1 = d2Seed.rand(nArea)
const r2 = d2Seed.rand(nArea)
const tmp = pCoord[r1]!
pCoord[r1] = pCoord[r2]!
pCoord[r2] = tmp
}
for (let i = 0; i < nArea; i += 1) {
const bx = pCoord[i]!.x
const by = pCoord[i]!.y
const candRect: Rect = { x: bx * TILES_PER_BLOCK, y: by * TILES_PER_BLOCK, w: wTiles, h: hTiles }
if (!testBlockArea(occupiedBlocks, gridWidth, gridHeight, bx, by, wBlocks, hBlocks, offset)) continue
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
chosenBx = bx
chosenBy = by
break
}
}
// Fallback deterministic scan if randomized shuffle found no valid slot
if (chosenBx === -1) {
for (let by = minBy; by <= maxBy; by += 1) {
for (let bx = minBx; bx <= maxBx; bx += 1) {
const candRect: Rect = { x: bx * TILES_PER_BLOCK, y: by * TILES_PER_BLOCK, w: wTiles, h: hTiles }
if (!testBlockArea(occupiedBlocks, gridWidth, gridHeight, bx, by, wBlocks, hBlocks, 0)) continue
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
chosenBx = bx
chosenBy = by
break
}
if (chosenBx !== -1) break
}
}
// Secondary fallback: on narrow/crowded maps where secondary border clusters occupy remaining blocks,
// allow placing over non-landmark blocks as long as it does not overlap any placed landmarks.
if (chosenBx === -1) {
for (let by = minBy; by <= maxBy; by += 1) {
for (let bx = minBx; bx <= maxBx; bx += 1) {
const candRect: Rect = { x: bx * TILES_PER_BLOCK, y: by * TILES_PER_BLOCK, w: wTiles, h: hTiles }
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
chosenBx = bx
chosenBy = by
break
}
if (chosenBx !== -1) break
}
}
if (chosenBx === -1) return false
// Claim blocks and stamp
for (let dx = 0; dx < wBlocks; dx += 1) {
for (let dy = 0; dy < hBlocks; dy += 1) {
occupiedBlocks.add(`${chosenBx + dx},${chosenBy + dy}`)
}
}
const tileX = chosenBx * TILES_PER_BLOCK
const tileY = chosenBy * TILES_PER_BLOCK
placedRects.push({ x: tileX, y: tileY, w: wTiles, h: hTiles })
const ds1 = findPiece(pattern)
if (ds1) {
stampDs1Safe(canvas, ds1, tileX, tileY)
}
landmarks.push({
name,
ds1Path,
gridX: chosenBx,
gridY: chosenBy,
tileX,
tileY,
widthTiles: wTiles,
heightTiles: hTiles,
roadExitTile: { x: tileX + wTiles / 2, y: tileY + hTiles / 2 },
})
notes.push(`Placed ${name} (${noteDesc}) at (${tileX}, ${tileY})`)
return true
}
// 3. Pond (Pond1.ds1, 2x2 blocks = 16x16 tiles)
spawn2DLandmark('Pond1.ds1', 'Act1/Outdoors/Pond1.ds1', /Pond/i, 2, 2, 'Pond')
// 4. Cottage (Cott1.ds1, 2x2 blocks = 16x16 tiles) - 1 unconditional, 2nd if space permits
spawn2DLandmark('Cott1.ds1', 'Act1/Outdoors/Cott1.ds1', /Cottage/i, 2, 2, 'Cottage')
spawn2DLandmark('Cott1.ds1', 'Act1/Outdoors/Cott1.ds1', /Cottage/i, 2, 2, 'Cottage')
// 5. Circle (Stone Fill 1, 1x1 block = 8x8 tiles)
spawn2DLandmark('Circle.ds1', 'Act1/Outdoors/Circle.ds1', /Circle|Stone.*1/i, 1, 1, 'Stone Fill 1')
// 6. Clover (Stone Fill 2, 1x1 block = 8x8 tiles)
spawn2DLandmark('Clover.ds1', 'Act1/Outdoors/Clover.ds1', /Clover|Stone.*2/i, 1, 1, 'Stone Fill 2')
return {
landmarks,
notes,
...(townTransition !== undefined ? { townTransition } : {}),
}
}
/**
* DRLGOUTDOORS_SpawnPresetFarAway
* Implements authentic D2 distance maximization:
* nAbsX <= nAbsY ? nAbsX + 2*nAbsY : nAbsY + 2*nAbsX
* with randomized scan start (DrlgOutdoors.cpp:260-338).
*/
export function DRLGOUTDOORS_SpawnPresetFarAway(options: {
gridWidth: number
gridHeight: number
occupiedBlocks: ReadonlySet<string>
placedRects: readonly Rect[]
baseX: number
baseY: number
seed?: D2Rng
presetWidthBlocks?: number
presetHeightBlocks?: number
offset?: number
}): { bx: number; by: number } | null {
const {
gridWidth,
gridHeight,
occupiedBlocks,
placedRects,
baseX,
baseY,
seed,
presetWidthBlocks = 2,
presetHeightBlocks = 2,
offset = 1,
} = options
const wTiles = presetWidthBlocks * TILES_PER_BLOCK
const hTiles = presetHeightBlocks * TILES_PER_BLOCK
const nWidth = gridWidth - presetWidthBlocks
const nHeight = gridHeight - presetHeightBlocks
if (nWidth < 0 || nHeight < 0) return null
const nRandX = seed ? seed.rand(nWidth + 1) : 0
const nRandY = seed ? seed.rand(nHeight + 1) : 0
let best: { bx: number; by: number } | null = null
let maxMetric = -1
for (let i = 0; i <= nHeight; i += 1) {
const nPosY = (i + nRandY) % (nHeight + 1)
for (let j = 0; j <= nWidth; j += 1) {
const nPosX = (j + nRandX) % (nWidth + 1)
const candRect: Rect = { x: nPosX * TILES_PER_BLOCK, y: nPosY * TILES_PER_BLOCK, w: wTiles, h: hTiles }
if (!testBlockArea(occupiedBlocks, gridWidth, gridHeight, nPosX, nPosY, presetWidthBlocks, presetHeightBlocks, offset)) continue
if (placedRects.some(r => rectsOverlap(r, candRect))) continue
const candCenterX = candRect.x + wTiles / 2
const candCenterY = candRect.y + hTiles / 2
const absX = Math.abs(candCenterX - baseX)
const absY = Math.abs(candCenterY - baseY)
const temp = absX <= absY ? absX + 2 * absY : absY + 2 * absX
const metric = Math.floor(temp / 2)
if (metric > maxMetric) {
maxMetric = metric
best = { bx: nPosX, by: nPosY }
}
}
}
// Fallback with offset 0 if margin check was overly restrictive
if (!best && offset > 0) {
return DRLGOUTDOORS_SpawnPresetFarAway({
...options,
offset: 0,
})
}
return best
}
function stampDs1Safe(canvas: CanvasLike, ds1: Ds1, originX: number, originY: number): void {
for (let y = 0; y < ds1.height; y += 1) {
const cy = originY + y
if (cy >= canvas.height) break
const targetRow = canvas.cells[cy]
const sourceRow = ds1.cells[y]
if (!targetRow || !sourceRow) continue
for (let x = 0; x < ds1.width; x += 1) {
const cx = originX + x
if (cx >= canvas.width) break
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
const sourceCell = sourceRow[x]
if (!targetCell || !sourceCell) continue
for (let i = 0; i < sourceCell.floors.length; i += 1) {
const floor = sourceCell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
if (i < targetCell.floors.length) {
targetCell.floors[i] = { ...floor }
} else {
targetCell.floors.push({ ...floor })
}
}
}
for (let i = 0; i < sourceCell.walls.length; i += 1) {
const wall = sourceCell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
if (i < targetCell.walls.length) {
targetCell.walls[i] = { ...wall }
} else {
targetCell.walls.push({ ...wall })
}
}
}
}
}
}

View File

@ -1,188 +0,0 @@
/**
* Authentic D2MOO v1.13c outer river placement & boundary suppression.
*
* Implements:
* - DRLGOUTWILD_SpawnRiver (D2Common/src/Drlg/DrlgOutWild.cpp:295)
* - Dual-column placement: URiverC.ds1 @ x = nGridWidth - 2, LRiverC.ds1 @ x = nGridWidth - 1
* - Boundary tree wall suppression along the river columns
* - Wooden bridge placement at path crossing
*/
import type { Ds1, Ds1Cell } from '../../formats/ds1.ts'
import {
TILES_PER_BLOCK,
OUTDOOR_NORTHWEST,
type D2OutdoorLevelContext,
} from './wilderness-tables.ts'
export interface MutableDs1Cell {
walls: Array<{ style: number; sequence: number; prop1: number; hidden?: boolean }>
floors: Array<{ style: number; sequence: number; prop1: number; hidden?: boolean }>
shadows?: Array<{ style: number; sequence: number; prop1: number; hidden?: boolean }>
}
export interface CanvasLike {
width: number
height: number
cells: Ds1Cell[][]
objects?: unknown[]
}
export interface RiverSpawnOptions {
readonly context: D2OutdoorLevelContext
readonly riverPieces?: {
readonly upper?: Ds1 | undefined
readonly lower?: Ds1 | undefined
readonly bridge?: Ds1 | undefined
readonly bridgeUpper?: Ds1 | undefined
readonly bridgeLower?: Ds1 | undefined
} | undefined
readonly townTransitionRow?: number | undefined
}
export interface RiverSpawnResult {
readonly placedBlocks: Array<{
readonly bx: number
readonly by: number
readonly type: 'upper' | 'lower' | 'bridge'
readonly name: string
}>
readonly suppressedBorderBlocks: Set<string> // "bx,by"
readonly bridgeRow?: number | undefined
}
/**
* Statically synthesizes or stamps river tiles (Style 2 = water floor) across outer boundary columns.
*/
export function DRLGOUTWILD_SpawnRiver(
canvas: CanvasLike,
options: RiverSpawnOptions,
): RiverSpawnResult {
const { context, riverPieces, townTransitionRow } = options
const { gridWidth, gridHeight, hasRiver, dwFlags } = context
const suppressedBorderBlocks = new Set<string>()
const placedBlocks: RiverSpawnResult['placedBlocks'] = []
if (!hasRiver) {
return { placedBlocks, suppressedBorderBlocks }
}
const upperCol = gridWidth - 2
const lowerCol = gridWidth - 1
// Determine bridge row (middle of level height, unless occupied by town transition)
let bridgeRow: number | undefined
if (gridHeight >= 4) {
const candidateRow = Math.floor(gridHeight / 2)
if (candidateRow !== townTransitionRow) {
bridgeRow = candidateRow
}
}
for (let by = 0; by < gridHeight; by += 1) {
// Suppress perimeter tree borders across all rows along both river columns
suppressedBorderBlocks.add(`${upperCol},${by}`)
suppressedBorderBlocks.add(`${lowerCol},${by}`)
// If this row is occupied by the Town Transition gate, the gate DS1 overlays the river
if (townTransitionRow !== undefined && by === townTransitionRow && (dwFlags & OUTDOOR_NORTHWEST)) {
continue
}
const isBridge = by === bridgeRow
const startX = upperCol * TILES_PER_BLOCK
const startY = by * TILES_PER_BLOCK
// If authentic DS1 is available, stamp it
if (riverPieces?.upper && riverPieces?.lower) {
if (isBridge && (riverPieces.bridgeUpper || riverPieces.bridge)) {
const bUpper = riverPieces.bridgeUpper ?? riverPieces.bridge!
const bLower = riverPieces.bridgeLower ?? riverPieces.bridge!
stampSubDs1(canvas, bUpper, startX, startY)
placedBlocks.push({ bx: upperCol, by, type: 'bridge', name: 'BridgeU.ds1' })
stampSubDs1(canvas, bLower, startX + TILES_PER_BLOCK, startY)
placedBlocks.push({ bx: lowerCol, by, type: 'bridge', name: 'BridgeL.ds1' })
} else {
stampSubDs1(canvas, riverPieces.upper, startX, startY)
placedBlocks.push({ bx: upperCol, by, type: 'upper', name: 'URiverC.ds1' })
stampSubDs1(canvas, riverPieces.lower, startX + TILES_PER_BLOCK, startY)
placedBlocks.push({ bx: lowerCol, by, type: 'lower', name: 'LRiverC.ds1' })
}
} else {
// Direct synthesis of river water cells: style 2 = water floor, prop1 = 2
// Continuous wooden plank bridge: style 4 = wooden bridge, prop1 = 194
for (let ty = 0; ty < TILES_PER_BLOCK; ty += 1) {
for (let tx = 0; tx < TILES_PER_BLOCK * 2; tx += 1) {
const cy = startY + ty
const cx = startX + tx
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const cell = canvas.cells[cy]?.[cx] as unknown as MutableDs1Cell | undefined
if (cell) {
if (isBridge && ty >= 2 && ty <= 5) {
// Wooden plank floor (style 4, prop1 194) spans continuously from tx=0 to tx=15
cell.floors = [{ style: 4, sequence: 1, prop1: 194, hidden: false }]
} else {
// Water floor (style 2, prop1 2)
cell.floors = [{ style: 2, sequence: (tx + ty) % 4, prop1: 2, hidden: false }]
}
}
}
}
}
if (isBridge) {
placedBlocks.push({ bx: upperCol, by, type: 'bridge', name: 'Bridge.ds1' })
placedBlocks.push({ bx: lowerCol, by, type: 'bridge', name: 'Bridge.ds1' })
} else {
placedBlocks.push({ bx: upperCol, by, type: 'upper', name: 'URiverC.ds1' })
placedBlocks.push({ bx: lowerCol, by, type: 'lower', name: 'LRiverC.ds1' })
}
}
}
return {
placedBlocks,
suppressedBorderBlocks,
...(bridgeRow !== undefined ? { bridgeRow } : {}),
}
}
function stampSubDs1(canvas: CanvasLike, ds1: Ds1, originX: number, originY: number): void {
for (let y = 0; y < ds1.height; y += 1) {
const cy = originY + y
if (cy >= canvas.height) break
const targetRow = canvas.cells[cy]
const sourceRow = ds1.cells[y]
if (!targetRow || !sourceRow) continue
for (let x = 0; x < ds1.width; x += 1) {
const cx = originX + x
if (cx >= canvas.width) break
const targetCell = targetRow[cx] as unknown as MutableDs1Cell | undefined
const sourceCell = sourceRow[x]
if (!targetCell || !sourceCell) continue
for (let i = 0; i < sourceCell.floors.length; i += 1) {
const floor = sourceCell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
if (i < targetCell.floors.length) {
targetCell.floors[i] = { ...floor }
} else {
targetCell.floors.push({ ...floor })
}
}
}
for (let i = 0; i < sourceCell.walls.length; i += 1) {
const wall = sourceCell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
if (i < targetCell.walls.length) {
targetCell.walls[i] = { ...wall }
} else {
targetCell.walls.push({ ...wall })
}
}
}
}
}
}

View File

@ -1,144 +0,0 @@
/**
* Authentic D2MOO v1.13c orthogonal dirt road graph and rasterizer.
*
* Implements:
* - DRLGOUTDOORS_SpawnAct1DirtPaths (D2Common/src/Drlg/DrlgOutdoors.cpp:999)
* - DRLG_OUTDOORS_GenerateDirtPath (D2Common/src/Drlg/DrlgOutdoors.cpp:910)
* - Width-2 Bresenham path rasterizer
* - 256-byte byte_6FDCF958 auto-tile LUT
*/
import type { CanvasLike, MutableDs1Cell } from './wilderness-river.ts'
import {
DIRT_PATH_TILE_LUT,
NEIGHBOR_OFFSETS,
type D2LandmarkPreset,
} from './wilderness-tables.ts'
export interface RoadPlanningOptions {
readonly widthTiles: number
readonly heightTiles: number
readonly landmarks: readonly D2LandmarkPreset[]
readonly bridgeRow?: number | undefined
readonly riverColumn?: number | undefined
}
export interface DirtRoadResult {
readonly roadCells: number
readonly roadSegments: number
readonly dirtPathGrid: Uint8Array
}
/**
* Connects town gate, den of evil, and level exits via an orthogonal width-2 dirt road network.
*/
export function DRLGOUTDOORS_SpawnAct1DirtPaths(
canvas: CanvasLike,
options: RoadPlanningOptions,
): DirtRoadResult {
const { widthTiles, heightTiles, landmarks, bridgeRow, riverColumn } = options
const dirtGrid = new Uint8Array(widthTiles * heightTiles)
const town = landmarks.find(l => /Town/i.test(l.name))
const den = landmarks.find(l => /Den|DOE/i.test(l.name))
const exitCol = riverColumn !== undefined ? riverColumn * 8 : widthTiles - 16
const exitY = bridgeRow !== undefined ? bridgeRow * 8 + 4 : Math.floor(heightTiles / 2)
// Endpoints:
const townPt = town?.roadExitTile ?? { x: Math.floor(widthTiles / 2), y: 4 }
const denPt = den?.roadExitTile ?? { x: 16, y: Math.floor(heightTiles / 2) }
const exitPt = { x: exitCol, y: exitY }
// Intermediate hub in the central clear area:
const hubPt = {
x: Math.round((townPt.x + exitPt.x) / 2),
y: Math.round((townPt.y + exitPt.y) / 2),
}
let roadSegments = 0
// Draw orthogonal segments: Town -> Hub -> Exit, and Den -> Hub
const drawOrthogonalLine = (x0: number, y0: number, x1: number, y1: number) => {
// Width-2 Bresenham / orthogonal steps
// Step 1: horizontal to x1
const xStep = x1 >= x0 ? 1 : -1
for (let x = x0; x !== x1 + xStep; x += xStep) {
stampRoadWidth2(dirtGrid, widthTiles, heightTiles, x, y0)
}
// Step 2: vertical to y1
const yStep = y1 >= y0 ? 1 : -1
for (let y = y0; y !== y1 + yStep; y += yStep) {
stampRoadWidth2(dirtGrid, widthTiles, heightTiles, x1, y)
}
roadSegments += 2
}
// 1. Town to Hub
drawOrthogonalLine(townPt.x, townPt.y, hubPt.x, hubPt.y)
// 2. Hub to Level Exit / Bridge
drawOrthogonalLine(hubPt.x, hubPt.y, exitPt.x, exitPt.y)
// 3. Den to Hub
drawOrthogonalLine(denPt.x, denPt.y, hubPt.x, hubPt.y)
// Auto-tile pass using byte_6FDCF958 LUT
let roadCells = 0
for (let y = 0; y < heightTiles; y += 1) {
for (let x = 0; x < widthTiles; x += 1) {
const idx = y * widthTiles + x
if (dirtGrid[idx] !== 1) continue
roadCells += 1
// Compute 8-neighbor bitmask in authentic D2 bit order
let mask = 0
for (const off of NEIGHBOR_OFFSETS) {
const nx = x + off.dx
const ny = y + off.dy
if (nx >= 0 && nx < widthTiles && ny >= 0 && ny < heightTiles) {
if (dirtGrid[ny * widthTiles + nx] === 1) {
mask |= off.mask
}
}
}
// Query LUT
let seq = DIRT_PATH_TILE_LUT[mask] ?? 0
if (seq === 0) seq = 1 // Fallback to straight road tile
const cell = canvas.cells[y]?.[x] as unknown as MutableDs1Cell | undefined
if (cell) {
// Authentic Act 1 wilderness dirt road: style 0, sequence from byte_6FDCF958 LUT, prop1 194
cell.floors = [
{ style: 0, sequence: seq, prop1: 194, hidden: false },
]
}
}
}
return {
roadCells,
roadSegments,
dirtPathGrid: dirtGrid,
}
}
function stampRoadWidth2(
grid: Uint8Array,
width: number,
height: number,
x: number,
y: number,
): void {
for (let dy = 0; dy <= 1; dy += 1) {
for (let dx = 0; dx <= 1; dx += 1) {
const px = x + dx
const py = y + dy
if (px >= 0 && px < width && py >= 0 && py < height) {
grid[py * width + px] = 1
}
}
}
}

View File

@ -1,283 +0,0 @@
/**
* Authentic D2MOO v1.13c lookup tables and interface contracts for outdoor wilderness generation.
*
* Ground Truth Sources:
* - D2Common/src/Drlg/DrlgOutPlace.cpp (stru_6FDD06C0, dword_6FDD05C0)
* - D2Common/src/Drlg/DrlgOutdoors.cpp (byte_6FDCF958)
* - D2Common/src/Drlg/DrlgOutWild.cpp (stru_6FDD0CA0)
* - D2Common/src/Drlg/DrlgTileSub.cpp (substitution groups & border rules)
*/
export const TILES_PER_BLOCK = 8
// Direction constants
export const ALTDIR_WEST = 0
export const ALTDIR_NORTH = 1
export const ALTDIR_EAST = 2
export const ALTDIR_SOUTH = 3
// pOutdoors->dwFlags
export const OUTDOOR_BRIDGE = 0x0004
export const OUTDOOR_RIVER_OTHER = 0x0008
export const OUTDOOR_RIVER = 0x0010
export const OUTDOOR_CLIFF_CAVES = 0x0020
export const OUTDOOR_PRESET_PLACED = 0x0040
export const OUTDOOR_SOUTHWEST = 0x0080
export const OUTDOOR_NORTHWEST = 0x0100
export const OUTDOOR_SOUTHEAST = 0x0200
export const OUTDOOR_NORTHEAST = 0x0400
// 8-neighbor bitmask constants
export const NEIGHBOR_MASK_NE = 0x80
export const NEIGHBOR_MASK_E = 0x40
export const NEIGHBOR_MASK_SE = 0x20
export const NEIGHBOR_MASK_N = 0x10
export const NEIGHBOR_MASK_S = 0x08
export const NEIGHBOR_MASK_NW = 0x04
export const NEIGHBOR_MASK_W = 0x02
export const NEIGHBOR_MASK_SW = 0x01
export interface NeighborOffset {
readonly dx: number
readonly dy: number
readonly mask: number
}
export const NEIGHBOR_OFFSETS: readonly NeighborOffset[] = [
{ dx: 1, dy: -1, mask: NEIGHBOR_MASK_NE },
{ dx: 1, dy: 0, mask: NEIGHBOR_MASK_E },
{ dx: 1, dy: 1, mask: NEIGHBOR_MASK_SE },
{ dx: 0, dy: -1, mask: NEIGHBOR_MASK_N },
{ dx: 0, dy: 1, mask: NEIGHBOR_MASK_S },
{ dx: -1, dy: -1, mask: NEIGHBOR_MASK_NW },
{ dx: -1, dy: 0, mask: NEIGHBOR_MASK_W },
{ dx: -1, dy: 1, mask: NEIGHBOR_MASK_SW },
]
/**
* stru_6FDD06C0: Connection flags mapping table for Act 1 outdoor levels.
* (DrlgOutPlace.cpp:1696)
*/
export interface OutdoorFlagTableEntry {
readonly nLevelId: number
readonly nExcludedLevel1: number
readonly nExcludedLevel2: number
readonly nRand: number
readonly nNextRand: number
readonly nFlags: number
}
export const OUTDOOR_FLAG_TABLE: readonly OutdoorFlagTableEntry[] = [
{ nLevelId: 0, nExcludedLevel1: 2, nExcludedLevel2: 3, nRand: 1, nNextRand: 0, nFlags: 0x04 },
{ nLevelId: 0, nExcludedLevel1: 2, nExcludedLevel2: 3, nRand: 2, nNextRand: 3, nFlags: 0x04 },
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 2, nNextRand: 1, nFlags: 0x08 },
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 3, nNextRand: 0, nFlags: 0x08 },
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 1, nNextRand: 1, nFlags: 0x10 },
{ nLevelId: 0, nExcludedLevel1: 3, nExcludedLevel2: 17, nRand: 3, nNextRand: 3, nFlags: 0x10 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 0, nNextRand: 0, nFlags: 0x08 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 2, nNextRand: 2, nFlags: 0x08 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 3, nNextRand: 0, nFlags: 0x08 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 3, nNextRand: 2, nFlags: 0x08 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 0, nNextRand: 1, nFlags: 0x400 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 1, nNextRand: 1, nFlags: 0x400 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 2, nNextRand: 1, nFlags: 0x200 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 2, nNextRand: 2, nFlags: 0x80 },
{ nLevelId: 2, nExcludedLevel1: 0, nExcludedLevel2: 0, nRand: 3, nNextRand: 2, nFlags: 0x100 },
]
/**
* dword_6FDD05C0: Act 1 Rogue Encampment connection validity lookup table.
* (DrlgOutPlace.cpp:1538)
*/
export const VALIDATION_TABLE_ACT1: readonly number[] = [
1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0,
1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1,
]
/**
* stru_6FDD0CA0: River upper & lower file variant mapping table.
* (DrlgOutWild.cpp:243)
*/
export const RIVER_PRESET_LUT = [
{ upperFile: 2, lowerFile: 2 },
{ upperFile: 0, lowerFile: 3 },
{ upperFile: 1, lowerFile: 1 },
{ upperFile: 3, lowerFile: 0 },
{ upperFile: 0, lowerFile: 2 },
{ upperFile: 0, lowerFile: 1 },
{ upperFile: 1, lowerFile: 0 },
{ upperFile: 2, lowerFile: 0 },
{ upperFile: 2, lowerFile: 3 },
{ upperFile: 1, lowerFile: 3 },
{ upperFile: 3, lowerFile: 1 },
{ upperFile: 3, lowerFile: 2 },
] as const
/**
* byte_6FDCF958: Authentic 256-byte D2 dirt path auto-tile lookup table.
* (DrlgOutdoors.cpp:874)
*/
export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x2B,
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27,
0x09, 0x09, 0x02, 0x2B, 0x09, 0x09, 0x2C, 0x1A,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27,
0x2A, 0x2A, 0x2E, 0x2A, 0x2A, 0x2A, 0x21, 0x1F,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x23, 0x14,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10,
0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15,
0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x25,
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16,
0x24, 0x24, 0x2D, 0x22, 0x24, 0x24, 0x1C, 0x1D,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07,
0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16,
0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01,
])
/**
* Pre-decoded substitution group metadata directly from Diablo II v1.13c DS1 files.
*/
export interface DrlgSubstGroupDef {
readonly nPosX: number
readonly nPosY: number
readonly nWidth: number
readonly nHeight: number
readonly field_14: number
}
// BorderCliffs.ds1 (15 groups)
export const BORDER_CLIFFS_GROUPS: readonly DrlgSubstGroupDef[] = [
{ nPosX: 0, nPosY: 60, nWidth: 5, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 45, nWidth: 5, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 30, nWidth: 5, nHeight: 5, field_14: 7 },
{ nPosX: 0, nPosY: 15, nWidth: 5, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 0, nWidth: 5, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 66, nWidth: 4, nHeight: 4, field_14: 3 },
{ nPosX: 0, nPosY: 51, nWidth: 4, nHeight: 4, field_14: 3 },
{ nPosX: 0, nPosY: 36, nWidth: 4, nHeight: 4, field_14: 3 },
{ nPosX: 0, nPosY: 21, nWidth: 4, nHeight: 4, field_14: 3 },
{ nPosX: 0, nPosY: 6, nWidth: 4, nHeight: 4, field_14: 3 },
{ nPosX: 0, nPosY: 71, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 56, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 41, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 26, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 11, nWidth: 3, nHeight: 3, field_14: 1 },
]
// BorderMiddle.ds1 (9 groups, generating Bord9..Bord12 2x3 interior closures)
export const BORDER_MIDDLE_GROUPS: readonly DrlgSubstGroupDef[] = [
{ nPosX: 0, nPosY: 7, nWidth: 6, nHeight: 6, field_14: 10 },
{ nPosX: 0, nPosY: 0, nWidth: 6, nHeight: 6, field_14: 12 },
{ nPosX: 0, nPosY: 33, nWidth: 5, nHeight: 6, field_14: 5 },
{ nPosX: 0, nPosY: 26, nWidth: 5, nHeight: 6, field_14: 6 },
{ nPosX: 0, nPosY: 20, nWidth: 6, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 14, nWidth: 6, nHeight: 5, field_14: 6 },
{ nPosX: 0, nPosY: 45, nWidth: 4, nHeight: 5, field_14: 1 },
{ nPosX: 0, nPosY: 40, nWidth: 5, nHeight: 4, field_14: 1 },
{ nPosX: 0, nPosY: 51, nWidth: 4, nHeight: 4, field_14: 1 },
]
// BorderCorner.ds1 (8 groups)
export const BORDER_CORNER_GROUPS: readonly DrlgSubstGroupDef[] = [
{ nPosX: 0, nPosY: 15, nWidth: 4, nHeight: 4, field_14: 2 },
{ nPosX: 0, nPosY: 10, nWidth: 4, nHeight: 4, field_14: 2 },
{ nPosX: 0, nPosY: 5, nWidth: 4, nHeight: 4, field_14: 2 },
{ nPosX: 0, nPosY: 0, nWidth: 4, nHeight: 4, field_14: 2 },
{ nPosX: 0, nPosY: 32, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 28, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 24, nWidth: 3, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 20, nWidth: 3, nHeight: 3, field_14: 1 },
]
// BorderBorder.ds1 (20 groups, generating Bord5..Bord8 outer peninsulas)
export const BORDER_BORDER_GROUPS: readonly DrlgSubstGroupDef[] = [
{ nPosX: 0, nPosY: 80, nWidth: 5, nHeight: 5, field_14: 10 },
{ nPosX: 0, nPosY: 52, nWidth: 5, nHeight: 5, field_14: 10 },
{ nPosX: 0, nPosY: 28, nWidth: 5, nHeight: 5, field_14: 10 },
{ nPosX: 0, nPosY: 0, nWidth: 5, nHeight: 5, field_14: 10 },
{ nPosX: 0, nPosY: 86, nWidth: 5, nHeight: 4, field_14: 5 },
{ nPosX: 0, nPosY: 58, nWidth: 4, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 34, nWidth: 5, nHeight: 4, field_14: 5 },
{ nPosX: 0, nPosY: 6, nWidth: 4, nHeight: 5, field_14: 5 },
{ nPosX: 0, nPosY: 95, nWidth: 4, nHeight: 4, field_14: 1 },
{ nPosX: 0, nPosY: 70, nWidth: 4, nHeight: 4, field_14: 1 },
{ nPosX: 0, nPosY: 43, nWidth: 4, nHeight: 4, field_14: 1 },
{ nPosX: 0, nPosY: 18, nWidth: 4, nHeight: 4, field_14: 1 },
{ nPosX: 0, nPosY: 91, nWidth: 5, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 64, nWidth: 3, nHeight: 5, field_14: 1 },
{ nPosX: 0, nPosY: 39, nWidth: 5, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 12, nWidth: 3, nHeight: 5, field_14: 1 },
{ nPosX: 0, nPosY: 100, nWidth: 4, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 75, nWidth: 3, nHeight: 4, field_14: 1 },
{ nPosX: 0, nPosY: 48, nWidth: 4, nHeight: 3, field_14: 1 },
{ nPosX: 0, nPosY: 23, nWidth: 3, nHeight: 4, field_14: 1 },
]
/* ------------------------------------------------------------------------- *
* Interface Contracts
* ------------------------------------------------------------------------- */
export interface D2OutdoorLevelContext {
readonly levelId: number
readonly levelName: string
readonly seed: number
readonly gridWidth: number // In blocks (8x8 tiles per block), e.g. 12 or 7 for Blood Moor
readonly gridHeight: number // In blocks, e.g. 7 or 12 for Blood Moor
readonly width: number // In tiles, gridWidth * 8 (e.g. 96 or 56)
readonly height: number // In tiles, gridHeight * 8 (e.g. 56 or 96)
readonly dwFlags: number // Bitflags: OUTDOOR_SOUTHWEST, OUTDOOR_NORTHWEST, OUTDOOR_BRIDGE, OUTDOOR_RIVER_OTHER
readonly hasRiver: boolean
readonly riverColumn: number // gridWidth - 2 if hasRiver
readonly origin: { readonly x: number; readonly y: number }
}
export interface D2OutdoorRoomGrid {
readonly gridWidth: number
readonly gridHeight: number
readonly rooms: (D2OutdoorRoom | null)[][] // [by][bx]
readonly riverBlocks: Array<{ bx: number; by: number; type: 'upper' | 'lower' | 'bridge_u' | 'bridge_l'; ds1: string }>
readonly borderOverrides: Map<string, string> // "bx,by" -> ds1 name
}
export interface D2OutdoorRoom {
readonly bx: number
readonly by: number
readonly ds1Name?: string
readonly flags?: number
}
export interface D2LandmarkPreset {
readonly name: string
readonly ds1Path: string
readonly gridX: number // In blocks
readonly gridY: number
readonly tileX: number // gridX * 8
readonly tileY: number
readonly widthTiles: number
readonly heightTiles: number
readonly roadExitTile?: { readonly x: number; readonly y: number }
}
export interface D2PathVertexGraph {
readonly vertices: Array<{ readonly x: number; readonly y: number }>
readonly edges: Array<[number, number]>
}

View File

@ -1,106 +0,0 @@
/**
* Authentic D2MOO v1.13c wilderness level linking & topology solver.
*
* Implements:
* - DRLGOUTPLACE_CreateLevelConnections (D2Common/src/Drlg/DrlgOutPlace.cpp)
* - Dynamic level dimensions (e.g. Blood Moor 96x56 horizontal vs 56x96 vertical)
* - Orientation and flags calculation (OUTDOOR_SOUTHWEST, OUTDOOR_NORTHWEST, OUTDOOR_BRIDGE, OUTDOOR_RIVER_OTHER)
*/
import {
TILES_PER_BLOCK,
OUTDOOR_FLAG_TABLE,
VALIDATION_TABLE_ACT1,
type D2OutdoorLevelContext,
} from './wilderness-tables.ts'
export interface Act1TopologyResult {
readonly levelId: number
readonly gridWidth: number
readonly gridHeight: number
readonly width: number
readonly height: number
readonly dwFlags: number
readonly hasRiver: boolean
readonly riverColumn: number
readonly r2_0: number
readonly r3_0: number
}
/**
* Solves the Act 1 wilderness level connection topology and dynamic dimensions.
* For Level 2 (Blood Moor), dynamically determines whether the map is 96x56 or 56x96
* and computes authentic connection flags from D2MOO PRNG simulation.
*/
export function solveAct1WildernessTopology(seed: number, levelId: number = 2): D2OutdoorLevelContext {
let low = seed >>> 0
let high = 666
const roll = (): number => {
const lSeed = BigInt(high) + 0x6AC690C5n * BigInt(low)
low = Number(lSeed & 0xFFFFFFFFn) >>> 0
high = Number((lSeed >> 32n) & 0xFFFFFFFFn) >>> 0
return low
}
// 1. DRLG_AllocDrlg: rolls initial start seed
roll()
// 2. sub_6FD823C0 iteration 0: Stony Field (Level 4) -> fixed offsets, 0 rolls
// 3. sub_6FD823C0 iteration 1: Cold Plains (Level 3) -> 1 roll
roll()
// 4. sub_6FD823C0 iteration 2: Blood Moor (Level 2) -> 2 rolls
const r2_0 = roll() & 3
const r2_2 = roll() & 1
const width = (r2_0 % 2 !== 0) ? 96 : 56
const height = (r2_0 % 2 !== 0) ? 56 : 96
const gridWidth = width / TILES_PER_BLOCK
const gridHeight = height / TILES_PER_BLOCK
// 5. sub_6FD823C0 iteration 3: Rogue Encampment (Level 1) -> 2 rolls
let r3_0 = roll() & 3
let r3_2 = roll() & 1
// Authentic D2MOO sub_6FD81720 state cycle with sub_6FD82050 validation lookup
for (let attempt = 0; attempt < 8; attempt += 1) {
const idx = r3_0 + 4 * (r3_2 + 2 * (r2_0 + 4 * r2_2))
if (VALIDATION_TABLE_ACT1[idx] === 1) break
const nextR3_0 = (r3_2 + r3_0) % 4
const nextR3_2 = (r3_2 + 1) % 2
r3_0 = nextR3_0
r3_2 = nextR3_2
}
// 6. sub_6FD82360: evaluate connection flags mapping from stru_6FDD06C0
let dwFlags = 0
for (const entry of OUTDOOR_FLAG_TABLE) {
if (entry.nLevelId === 2 || entry.nLevelId === 0) {
if (entry.nLevelId !== entry.nExcludedLevel1 && entry.nLevelId !== entry.nExcludedLevel2) {
if (entry.nRand === r2_0 && entry.nNextRand === r3_0) {
dwFlags |= entry.nFlags
}
}
}
}
// 7. DrlgOutWild.cpp:113: River is spawned if dwFlags & (OUTDOOR_RIVER_OTHER 0x08 | OUTDOOR_BRIDGE 0x04)
const hasRiver = (dwFlags & 0x0C) !== 0
const riverColumn = gridWidth - 2
return {
levelId,
levelName: levelId === 2 ? 'Blood Moor' : 'Act 1 - Wilderness',
seed,
gridWidth,
gridHeight,
width,
height,
dwFlags,
hasRiver,
riverColumn,
origin: { x: 0, y: 0 },
}
}

View File

@ -181,11 +181,17 @@ export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [
// Act 1. 1 Rogue Encampment, 2 Blood Moor, 3 Cold Plains, 4 Stony Field,
// 5 Dark Wood, 6 Black Marsh, 7 Tamoe Highland, 17 Burial Grounds,
// 26 Monastery Gate.
//
// D2 lays Act 1 out as two separate groups (DrlgOutPlace.cpp
// gAct1WildernessDrlgLink @ D2Common.0x6FDCFE40: 4, 3, 2, 1, 17 around the
// Stony Field at its Levels.txt offset 1000,1000; gAct1MonasteryDrlgLink @
// 0x6FDCFF30: 26, 7, 6, 5 around the Monastery Gate at 3000,1000). The Stony
// Field and the Dark Wood never touch: they are joined only through the
// Underground Passage (level 10, a warp on both sides).
[1, 2],
[2, 3],
[3, 4],
[3, 17],
[4, 5],
[5, 6],
[6, 7],
[7, 26],

View File

@ -29,8 +29,13 @@ interface AnchorSpec {
* Keyed by 0-based act index (0..4).
*/
export const ACT_ANCHORS: Readonly<Record<number, readonly AnchorSpec[]>> = {
// Act 1: Cold Plains (Level 3) at (1000, 1000)
0: [{ levelId: 3, offsetX: 1000, offsetY: 1000, sizeX: 80, sizeY: 80 }],
// Act 1: Cold Plains (Level 3) at (1000, 1000); the Monastery Gate (Level 26) at its Levels.txt
// offset (3000, 1000), anchoring the second level group (26, 7, 6, 5), which D2 places apart from the
// first (DrlgOutPlace.cpp gAct1MonasteryDrlgLink @ D2Common.0x6FDCFF30).
0: [
{ levelId: 3, offsetX: 1000, offsetY: 1000, sizeX: 80, sizeY: 80 },
{ levelId: 26, offsetX: 3000, offsetY: 1000, sizeX: 64, sizeY: 18 },
],
// Act 2: Lut Gholein (Level 40) at (1000, 1000), Canyon of Magi (Level 46) at (2500, 1000)
1: [
{ levelId: 40, offsetX: 1000, offsetY: 1000, sizeX: 56, sizeY: 56 },

View File

@ -93,7 +93,8 @@ import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-link
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
import { TownPortalSlot, WaypointNetwork, resolveTownPortalSubTile, townLevelForAct, type OpenPortal } from '../game/portal.ts'
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
import type { Side } from '../game/variants.ts'
import { WorldVariants, WORLD_VARIANT_SEED, type ActLayout } from '../game/act-variants.ts'
import { Minimap } from '../ui/minimap.ts'
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
import { HudManager } from '../ui/hud-manager.ts'
@ -107,22 +108,6 @@ const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
const DEFAULT_PACKS = ['/diablo2/packs', 'samples/d2-packs']
/** Act count the page offers. */
const ACT_COUNT = 5
/**
* Picks which baked variant of a generated level this world instance uses.
*
* Each generated level is baked three times and the world graph names none of
* them, so one has to be chosen at load time. It must be chosen from the level
* alone: keying it on the pair of levels, as an earlier version did, gave Cold
* Plains a different layout depending on whether you walked in from the Blood
* Moor or back from Stony Field, and left waypoints and town portals pointing
* at coordinates from a map that was no longer loaded. One variant per level
* per world is what the design calls for.
*
* A real game would derive this from the game seed; until there is one, a
* constant keeps every session identical, which is also what the verification
* scripts assume.
*/
const WORLD_VARIANT_SEED = 0x5eed_3000
/** Walk speed in scene pixels per second (cells are 80×40). */
const WALK_SPEED = 170
/** Character collision box at the feet, in scene pixels. */
@ -5276,6 +5261,8 @@ async function fetchJson<T>(url: string): Promise<T> {
interface PackIndex {
readonly palettes?: Record<string, number[]>
readonly levels: readonly PackIndexEntry[]
/** DRLG-generated copies of an act, keyed by act number (see act-variants.ts). */
readonly actLayouts?: Readonly<Record<string, readonly ActLayout[]>>
}
/**
@ -5296,6 +5283,10 @@ interface PackIndexEntry {
readonly levelName: string
readonly path: string
readonly pages: number
/** DS1 member (presets) or generator tag the map was baked from. */
readonly ds1?: string
/** DRLG act variant of a generated level (see act-variants.ts). */
readonly actVariant?: number
}
/** Object frame inside an object atlas page. */
@ -5414,9 +5405,37 @@ async function getPackIndex(packBase: string): Promise<PackIndex | null> {
}
}
/**
* The per-session choice of level copies, one chooser per pack (see act-variants.ts).
*
* It lives beside the index cache because it is part of the same world: every transition in the
* session must agree on which generated copy of an act is loaded.
*/
const worldVariantsCache = new Map<string, WorldVariants<PackIndexEntry>>()
/**
* The pack's copy chooser, created on first use.
*
* @param packBase - directory holding `index.json`.
* @param index - the pack's already-fetched index.
* @returns the chooser shared by every load from this pack.
*/
function worldVariantsFor(packBase: string, index: PackIndex): WorldVariants<PackIndexEntry> {
let variants = worldVariantsCache.get(packBase)
if (variants === undefined) {
variants = new WorldVariants(index, WORLD_VARIANT_SEED)
worldVariantsCache.set(packBase, variants)
}
return variants
}
/**
* Build the runtime for a prebaked pack.
*
* An explicitly named copy (`?level=`) decides its act's generated variant, so walking on from it
* stays inside the act it was generated with. Without one the act's town opens: in a DRLG-generated
* act that is the town file the chosen copy of the act fixed (its gate faces the generated level).
*
* @param packBase - directory holding `index.json`.
* @param act - act to open.
* @param wantedLevel - `?level=` label, when the caller picked one.
@ -5433,28 +5452,27 @@ async function loadPackRuntime(
if (index === null) return null
const forAct = index.levels.filter(entry => entry.act === act)
if (forAct.length === 0) return null
const entry = (wantedLevel === ''
const variants = worldVariantsFor(packBase, index)
const wanted = wantedLevel === ''
? undefined
: forAct.find(candidate => candidate.label === wantedLevel)
?? forAct.find(candidate => candidate.slug === wantedLevel))
?? getDefaultTownVariant(forAct, act)
?? forAct.find(candidate => candidate.slug.includes('town'))
?? forAct[0]!
?? forAct.find(candidate => candidate.slug === wantedLevel)
if (wanted !== undefined) variants.pin(wanted)
const entry = wanted ?? variants.startEntry(act)
if (entry === null) return null
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
/**
* Build the runtime for one level, named by id rather than by label.
*
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
* on the connecting border orientation (`fromSide`) so that the gate and road
* seamlessly align across the level boundary. If no directional requirement exists,
* it picks deterministically via `variantSeed`.
* The copy comes from the session's {@link WorldVariants}: a level of a DRLG-generated act uses the
* act's chosen copy (the town uses the file that copy fixed); any other level with several baked
* copies (Lut Gholein LutN/LutW, Cloister Courtyard N/E/W, ...) prefers the copy whose opening faces
* the side the player arrives from, so the gate and road line up across the border.
*
* @param packBase - directory holding `index.json`.
* @param levelId - `Levels.txt` `Id` of the wanted level.
* @param variantSeed - picks between the variants deterministically.
* @param viewport - current viewport dimensions.
* @param charBases - character assets bases.
* @param fromSide - the compass edge of the source level the player walked off of.
@ -5463,7 +5481,6 @@ async function loadPackRuntime(
async function loadRuntimeForLevel(
packBase: string,
levelId: number,
variantSeed: number,
viewport: { width: number; height: number },
charBases: readonly string[],
fromSide?: Side,
@ -5472,21 +5489,13 @@ async function loadRuntimeForLevel(
const index = await getPackIndex(packBase)
if (index === null) return null
const candidates = index.levels.filter(entry => entry.levelId === levelId)
if (candidates.length === 0) return null
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
let entry: PackIndexEntry | undefined
if (requiredSide === undefined && preferredVariantLabel !== undefined) {
entry = candidates.find(
const preferred = preferredVariantLabel !== undefined && fromSide === undefined
? candidates.find(
c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel,
)
}
if (entry === undefined && requiredSide === undefined && isTownLevel(levelId)) {
entry = getDefaultTownVariant(candidates, candidates[0]!.act)
}
if (entry === undefined) {
entry = findMatchingVariant(candidates, requiredSide, variantSeed)
}
if (entry === undefined) return null
: undefined
const entry = preferred ?? worldVariantsFor(packBase, index).entryForLevel(levelId, fromSide)
if (entry === null) return null
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
@ -5538,7 +5547,16 @@ async function buildPackRuntime(
}
const floors = scene.floors.map(toDrawable)
const WILDERNESS_LEVEL_IDS = new Set([2, 3, 4, 5, 6, 7, 17, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83, 104, 105, 106, 110, 111, 112, 117, 134])
if (entry.levelId !== undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) {
// The fill below covers floor gaps left by the older wilderness generator
// (Acts 2-5). It must not touch scenes baked from the DRLG port (the entries
// that carry an `actVariant`, i.e. the Act I outdoor levels). There, a cell
// without a floor is a cell D2 leaves blank, and D2 draws blank cells as void:
// the grid cells behind the tree borders that
// DRLGOUTPLACE_SetBlankBorderGridCells (D2Common.0x6FD80C80) marks, and the
// extra last DS1 column and row of the (width + 1) x (height + 1) canvas. The
// bake blocks those cells in the collision map, so grass painted there would
// show ground nobody can walk on.
if (entry.levelId !== undefined && entry.actVariant === undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) {
const covered = new Uint8Array(scene.cellsX * scene.cellsY)
const freq = new Map<number, number>()
for (let i = 0; i < scene.floors.length; i += 1) {
@ -5701,8 +5719,9 @@ async function buildPackRuntime(
gridWidth: scene.collision.width,
}
const spawn = scene.spawn === null || scene.spawn === undefined
? (findIsoSpawn(grid) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 })
? findIsoSpawn(grid)
: { x: scene.spawn[0] ?? 0, y: scene.spawn[1] ?? 0 }
if (spawn === null) throw new Error(`${entry.label}: the map has no walkable spawn`)
// The pack carries the act palette, which the character atlas needs; without it
// the character simply stays a marker rather than rendering in wrong colours.
@ -8197,8 +8216,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const next = await loadRuntimeForLevel(
runtime.base,
toLevelId,
// Per level, not per pair: see WORLD_VARIANT_SEED.
WORLD_VARIANT_SEED + toLevelId,
{ width: canvas.width, height: canvas.height },
runtime.charBases,
fromSide,
@ -8235,16 +8252,15 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
// The destination's own opening back to where we came from is the landing
// spot; an override wins, because a waypoint or a portal lands somewhere
// that no edge describes.
// that no edge describes. Every walkable link has both ends, so a missing
// way back is a broken world graph, never a reason to drop the player in
// the middle of the map.
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
const rawLanding = effectiveOverride
?? (back === undefined
? {
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2),
if (effectiveOverride === undefined && back === undefined) {
throw new Error(`level ${String(toLevelId)} has no link back to level ${String(fromLevelId)}`)
}
: { x: back.arriveX, y: back.arriveY })
const rawLanding = effectiveOverride ?? { x: back!.arriveX, y: back!.arriveY }
let landing = rawLanding
const activeMouthInNext = portalSlot.mouthInLevel(next.levelId)
if (
@ -8306,7 +8322,17 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
return
}
const chosenEntry = pool[Math.floor(Math.random() * pool.length)]!
// The session's copy of the level, exactly as walking there would load it
// (a random copy could belong to another generated variant of the act).
const levelIds = [...new Set(pool.map(entry => entry.levelId))]
const poolLevelId = levelIds.length === 1 ? levelIds[0] : undefined
if (poolLevelId === undefined && pool.length !== 1) {
throw new Error(`waypoint ${slug}: ${String(pool.length)} baked maps match and they do not name one level`)
}
const chosenEntry = poolLevelId === undefined
? pool[0]!
: worldVariantsFor(runtime.base, index).entryForLevel(poolLevelId)
if (chosenEntry === null) throw new Error(`waypoint ${slug}: no baked copy of level ${String(poolLevelId)}`)
const next = await buildPackRuntime(
runtime.base,
index,
@ -8319,12 +8345,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
})
const wp = next.waypoints[0]
const landing = wp !== undefined
? { x: wp.arriveX, y: wp.arriveY }
: {
x: Math.floor((next.grid.cellsX * SUB_TILES_PER_TILE) / 2),
y: Math.floor((next.grid.cellsY * SUB_TILES_PER_TILE) / 2),
}
if (wp === undefined) throw new Error(`${chosenEntry.label} has no waypoint to arrive at`)
const landing = { x: wp.arriveX, y: wp.arriveY }
await swapLevel(next, landing)
preloadRemaining(next, renderer)

View File

@ -113,7 +113,7 @@ describe('Adversarial Challenger M3 Gen2: World Placement & Graph Topology', ()
expect(failureList).toEqual([])
expect(passedChecks).toBe(totalChecks)
expect(totalChecks).toBe(2700) // 27 seamless pairs * 100 seeds
expect(totalChecks).toBe(2600) // 26 seamless pairs * 100 seeds
})
it('Property 2a: 0 interior overlaps between any pair of seamless levels across all 100 seeds (9,900 checks)', () => {

View File

@ -151,9 +151,13 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
expect(kindsCount.preset).toBe(65)
expect(kindsCount.wilderness).toBe(90)
expect(kindsCount.maze).toBe(210)
expect(totalFloors).toBe(1020280)
expect(totalWalls).toBe(231464)
expect(totalPngs).toBe(1433)
// Measured on the full re-bake after the Act I outdoor levels (2-7, 17, 39) moved to
// the D2MOO DRLG port. For reference, a fresh bake of the previous generator gave
// 1020690 floors, 231738 walls and 1401 PNGs; the old pins (1020280 / 231464 / 1433)
// no longer matched any bake of the current code.
expect(totalFloors).toBe(1008898)
expect(totalWalls).toBe(237542)
expect(totalPngs).toBe(1390)
})
it('verifies world graph integrity and edge consistency with index.json', () => {
@ -163,7 +167,9 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
expect(graph.version).toBe(1)
expect(graph.levels.length).toBe(136)
expect(graph.edges.length).toBe(286)
// 284: the Stony Field <-> Dark Wood seam (4 <-> 5, one edge each way) was invented and
// is gone; in D2 the two levels are joined only through the Underground Passage.
expect(graph.edges.length).toBe(284)
expect(graph.waypoints.length).toBe(39)
// All edge levels must exist in levels list

View File

@ -9,11 +9,11 @@
* - Verify generated wilderness levels across Acts 2..5 exhibit multi-sequence ground variation (not just sequence 0).
* - Verify Act 3 Kurast (Levels 79, 80, 81, 82) ground tiles use strictly style: 1 and never style: 0.
* - Verify Level 117 (Act 5 Barricade Snow) uses strictly style: 6 (snow) and never style: 0 (dirt).
* 2. Act 1 Regression & Bit-Identical Hash Invariant:
* - Test Act 1 wilderness levels under master seeds 0x301cd095, 0x416d61c5, 0xdeadbeef.
* - Confirm all 24 baseline hashes match 100% bit-identically.
* 3. Offline Pre-Bake Assets Invariance:
* 2. Offline Pre-Bake Assets Invariance:
* - Validate 136-level pre-baked pack scenes in samples/d2-packs/ with 0 missing tiles.
*
* Act I outdoor levels come from the DRLG port (src/game/drlg); their bit-exact parity with D2 is
* tests/drlg-act1-oracle.test.ts, which replaced the old Act 1 baseline-hash invariant.
*/
import { describe, expect, it, beforeAll } from 'vitest'
@ -39,64 +39,8 @@ import {
type WeightedGroundTile,
} from '../src/game/wilderness.ts'
/* ------------------------------------------------------------------------- *
* FNV-1a Canonical Map Hasher (identical to wilderness-seed-sensitivity)
* ------------------------------------------------------------------------- */
class Hasher {
private hash = 2166136261
push(value: number): this {
const word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
return hasher.digest
}
const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
4: ['378d6251', '23f8b483', '57598911'], // Stony Field
5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
}
/* ------------------------------------------------------------------------- *
* Mock Helpers for Hermetic Ground Stress Testing
* ------------------------------------------------------------------------- */
@ -202,15 +146,6 @@ describe('M4 Adversarial Challenge: Canonical Ground Pools Contract', () => {
expect(pool.filter(t => t.style === 0).length).toBe(0)
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).not.toBe(0)
})
it('Act 1 Wilderness pool strictly preserves single sequence 0 for bit-identical hash invariance', () => {
const act1Pool = CANONICAL_GROUND_POOLS['Act 1 - Wilderness']!
expect(act1Pool).toBeDefined()
expect(act1Pool.length).toBe(1)
expect(act1Pool[0]!.style).toBe(0)
expect(act1Pool[0]!.sequence).toBe(0)
expect(act1Pool[0]!.weight).toBe(1)
})
})
describe('M4 Adversarial Challenge: fillGround Sampling & Statistical Variety', () => {
@ -389,15 +324,6 @@ describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verifi
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -416,7 +342,7 @@ describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verifi
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
@ -494,22 +420,6 @@ describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verifi
})
}
it('Act 1: All 24 baseline hashes match 100% bit-identically across master test seeds', async () => {
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
for (const levelId of act1Levels) {
const expected = ACT1_BASELINE_HASHES[levelId]!
const actual: string[] = []
for (const seed of TEST_SEEDS) {
const result = await generateLevelForSeed(levelId, seed)
actual.push(canonicalHash(result.level))
}
expect(actual).toEqual(expected)
}
}, 60000)
it('Act 3 Kurast (Levels 79, 80, 81, 82): ground tiles strictly use style 1 and NEVER style 0', async () => {
const kurastLevels = [79, 80, 81, 82]

View File

@ -0,0 +1,332 @@
/**
* Act I outdoor DRLG: what the rest of the game relies on in the levels the D2MOO port (src/game/drlg)
* generates, checked on more seeds than the oracle fixtures, on the maps the bake makes from them
* (src/game/drlg/drlg-map.ts) and on the collision scenes it builds (src/game/d2map.ts).
*
* tests/drlg-act1-oracle.test.ts proves the port is D2MOO roll for roll on four seeds; this suite checks
* invariants on any seed:
* - sizes: the act layout gives every level its Levels.txt size (the Blood Moor either way round, as
* DRLGOUTPLACE_CreateLevelConnections orients it);
* - population: one waypoint on Cold Plains..Black Marsh and five shrine cells on Blood Moor..Tamoe
* Highland (DrlgOutWild.cpp:212-220), the SuperUnique presets, Flavie at the camp gate;
* - links: every seam lies on an edge the act layout shares with the neighbour and is mirrored by it,
* and the seams and warps are exactly the world graph's (the bake's exactLinks check throws otherwise);
* - reachability: in the bake's collision scene every seam opens onto the level's main walkable region,
* every warp and waypoint can be triggered from it, the SuperUniques stand next to it and the spawn is
* on it;
* - determinism.
*
* It replaces the Act I suites of the deleted hand-written generator (wilderness-reachability-stress,
* wilderness-stress-fuzz). Needs the 1.13c archives in samples/d2; skipped without them, like the other
* MPQ suites (the oracle suite is the one that fails instead).
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { parseTable } from '../src/game/acts.ts'
import { buildIsoMapScene, subTileAt } from '../src/game/d2map.ts'
import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts'
import {
act1OutdoorLevelIds,
buildDrlgLevelMap,
drlgLevelInputFromDump,
type DrlgLevelInput,
type DrlgLevelMap,
} from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts'
import {
findBorderOpening,
findIsoSpawn,
largestWalkableRegion,
nearestWalkable,
triggerableFrom,
WARP_TRIGGER_SUBTILES,
type BorderOpening,
} from '../src/game/level-links.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { buildWorldGraph, edgesFrom, oppositeSide, parseLevelRows, type WorldGraph } from '../src/game/world-graph.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
/** The Act I levels Levels.txt generates with the outdoor DRLG (checked against the table below). */
const LEVELS = [2, 3, 4, 5, 6, 7, 17, 39]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
const LEVELS_TXT = 'data\\global\\excel\\Levels.txt'
const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name)))
function lcgSeeds(start: number, count: number): number[] {
const out: number[] = []
let seed = start
for (let i = 0; i < count; i += 1) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
out.push(seed)
}
return out
}
/** The game seeds the bake generates its three Act I copies from (scripts/pack-act-assets.ts actGameSeed). */
const BAKE_SEEDS = [0x5eed0100, 0x5eed0101, 0x5eed0102]
/** Fully generated and activated: the oracle fixture seed, the bake's seeds and pseudo-random ones. */
const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x0badf00d, 12)]
/** How many of SEEDS also get the (slow) collision-scene checks. */
const SCENE_SEEDS = 6
/** Act layout only, for the size sweep. */
const LAYOUT_SEEDS = lcgSeeds(0x2468ace1, 256)
// DRLGOUTWILD_InitAct1OutdoorLevel (DrlgOutWild.cpp:212-220).
/** LEVEL_COLDPLAINS..LEVEL_BLACKMARSH: DRLGOUTDOORS_SpawnAct12Waypoint. */
const WAYPOINT_LEVELS = new Set([3, 4, 5, 6])
/** LEVEL_BLOODMOOR..LEVEL_TAMOEHIGHLAND: DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5). */
const SHRINE_LEVELS = new Set([2, 3, 4, 5, 6, 7])
const SHRINES_PER_LEVEL = 5
/** Grid-1 cell flags: waypoint 0x10000 / 0x20000 (DrlgOutdoors.cpp:520, :565), shrine 0x1000..0x8000 (:581). */
const GRID1_WAYPOINT = 0x30000
const GRID1_SHRINE = 0xf000
/** Objects.txt SubClass bits. */
const SUBCLASS_SHRINE = 0x01
const SUBCLASS_WAYPOINT = 0x40
/** D2PresetUnitStrc nUnitType. */
const UNIT_MONSTER = 1
const UNIT_OBJECT = 2
/** MonStats.txt hcIdx 266 (navi): Flavie, part of the Blood Moor's camp transition preset. */
const MONSTATS_FLAVIE = 266
/** The fixed monsters each level's presets carry (drlg-map.ts landmarks). */
const LANDMARKS: Readonly<Record<number, readonly string[]>> = {
2: [],
3: ['Bishibosh'],
4: ['Rakanishu'],
5: ['Treehead WoodFist'],
6: [],
7: [],
17: ['Blood Raven'],
39: ['The Cow King'],
}
/**
* How close main-region ground must be to a SuperUnique's preset position, in sub-tiles. Some stand on
* blocked ground (Rakanishu inside the Cairn Stones: up to 10 sub-tiles measured); D2Game places the
* monster on free ground around its preset, and that ground must be reachable.
*/
const LANDMARK_REACH = 3 * SUB_TILES_PER_TILE
const numeric = (a: number, b: number): number => a - b
function hex(seed: number): string {
return `0x${seed.toString(16).padStart(8, '0')}`
}
/** The stretch two act-layout rectangles share along one side of `a`, or null if they do not touch there. */
function sharedStretch(a: DumpCoord, b: DumpCoord, side: string): { lo: number; hi: number } | null {
const [ax, ay, aw, ah] = a
const [bx, by, bw, bh] = b
let touching: boolean
let lo: number
let hi: number
if (side === 'east' || side === 'west') {
touching = side === 'east' ? ax + aw === bx : bx + bw === ax
lo = Math.max(ay, by)
hi = Math.min(ay + ah, by + bh)
} else {
touching = side === 'south' ? ay + ah === by : by + bh === ay
lo = Math.max(ax, bx)
hi = Math.min(ax + aw, bx + bw)
}
return touching && hi > lo ? { lo, hi } : null
}
describe.skipIf(!hasMpq)('Act I outdoor DRLG port: level invariants over many seeds', () => {
let data: DrlgMpqData
let superUniqueIds: string[]
let worldGraph: WorldGraph
const dt1Cache = new Map<string, Dt1>()
beforeAll(async () => {
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 1)
superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
worldGraph = buildWorldGraph(parseLevelRows(parseTable(await archives.read(LEVELS_TXT))))
}, 120_000)
function generate(seed: number): DrlgDump {
return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false })
}
function mapLevel(dump: DrlgDump, levelId: number): { input: DrlgLevelInput; map: DrlgLevelMap } {
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
return { input, map }
}
function library(name: string): Dt1 {
let lib = dt1Cache.get(name)
if (lib === undefined) {
lib = decodeDt1(data.source.readFile(name))
dt1Cache.set(name, lib)
}
return lib
}
it('takes the outdoor level list from Levels.txt', () => {
expect(act1OutdoorLevelIds(data.tables)).toEqual(LEVELS)
})
it(`sizes every level as D2 does on ${LAYOUT_SEEDS.length} act layouts`, () => {
const env = createDrlgEnv(data.source, data.tables)
const bloodMoor = new Set<string>()
for (const seed of LAYOUT_SEEDS) {
const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true })
for (const levelId of LEVELS) {
const level = act.find(l => l.id === levelId)
expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined()
const [, , width, height] = level!.coord
if (levelId === 2) {
// The Blood Moor's link callback (sub_6FD81950, DrlgOutPlace.cpp:1310; gAct1WildernessDrlgLink,
// :33) replaces its Levels.txt size with 96x56 or 56x96, whichever way it runs from the camp.
bloodMoor.add(`${width}x${height}`)
continue
}
// Every other level keeps Levels.txt SizeX/SizeY (DRLGOUTPLACE_CreateLevelConnections).
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
}
}
expect([...bloodMoor].sort()).toEqual(['56x96', '96x56'])
}, 120_000)
it('generates the same act from the same seed', () => {
expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!)))
}, 60_000)
it('generates a different level from every seed', () => {
const env = createDrlgEnv(data.source, data.tables)
const grids = new Map<number, Set<string>>(LEVELS.map(id => [id, new Set<string>()]))
for (const seed of SEEDS) {
const dump = dumpAct1(env, seed, LEVELS, { difficulty: 0, isolated: false, skipActivation: true })
for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids))
}
for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length)
}, 60_000)
SEEDS.forEach((seed, index) => {
const withScene = index < SCENE_SEEDS
it(`seed ${hex(seed)}: population and links${withScene ? ', and reachability in the collision scene' : ''}`, () => {
const dump = generate(seed)
const act = new Map(dump.act.map(l => [l.id, l]))
const maps = new Map(LEVELS.map(levelId => [levelId, mapLevel(dump, levelId)]))
for (const levelId of LEVELS) {
const what = `${hex(seed)} level ${levelId}`
const { input, map } = maps.get(levelId)!
// ---- the canvas ----
const layout = act.get(levelId)!.coord
expect([map.coord.x, map.coord.y, map.coord.width, map.coord.height], what).toEqual(layout)
expect(map.ds1.width, what).toBe(map.coord.width + 1)
expect(map.ds1.height, what).toBe(map.coord.height + 1)
// ---- population ----
const cells = dump.levels.find(l => l.id === levelId)!.levelGrid.grids[1]!.cells
expect(cells.filter(c => (c & GRID1_WAYPOINT) !== 0).length, `${what}: waypoint cells`).toBe(WAYPOINT_LEVELS.has(levelId) ? 1 : 0)
expect(cells.filter(c => (c & GRID1_SHRINE) !== 0).length, `${what}: shrine cells`)
.toBe(SHRINE_LEVELS.has(levelId) ? SHRINES_PER_LEVEL : 0)
const objects = input.units.filter(u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length)
const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0)
const shrines = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_SHRINE) !== 0)
// The world graph registers the waypoint from Levels.txt `Waypoint`; the map must have it.
const hasWaypoint = worldGraph.levels.get(levelId)!.waypoint !== 255
expect(hasWaypoint, what).toBe(WAYPOINT_LEVELS.has(levelId))
expect(waypoints, `${what}: waypoint objects`).toHaveLength(hasWaypoint ? 1 : 0)
if (SHRINE_LEVELS.has(levelId)) {
expect(shrines.length, `${what}: shrine objects`).toBeGreaterThanOrEqual(1)
expect(shrines.length, `${what}: shrine objects`).toBeLessThanOrEqual(SHRINES_PER_LEVEL)
} else {
expect(shrines, `${what}: shrine objects`).toHaveLength(0)
}
const flavie = input.units.filter(u => u.type === UNIT_MONSTER && u.index === MONSTATS_FLAVIE)
expect(flavie, `${what}: Flavie`).toHaveLength(levelId === 2 ? 1 : 0)
expect(map.landmarks.map(l => l.id).sort(), `${what}: landmarks`).toEqual([...LANDMARKS[levelId]!].sort())
for (const unit of [...input.units, ...map.landmarks]) {
expect(unit.subX >= 0 && unit.subY >= 0 && unit.subX < map.ds1.width * SUB_TILES_PER_TILE
&& unit.subY < map.ds1.height * SUB_TILES_PER_TILE, `${what}: unit at ${unit.subX},${unit.subY} is on the canvas`).toBe(true)
}
// ---- links: exactly the world graph's, on shared edges, mirrored by outdoor neighbours ----
const gates = map.entrances.filter(e => e.kind === 'gate')
const warps = map.entrances.filter(e => e.kind === 'preset')
const graphEdges = edgesFrom(worldGraph, levelId)
expect(gates.map(e => e.toLevelId).sort(numeric), `${what}: seams`)
.toEqual(graphEdges.filter(e => e.kind === 'seamless').map(e => e.to).sort(numeric))
expect([...new Set(warps.map(e => e.toLevelId))].sort(numeric), `${what}: warps`)
.toEqual(graphEdges.filter(e => e.kind === 'warp').map(e => e.to).sort(numeric))
expect(warps.length, `${what}: one warp unit per warp`).toBe(new Set(warps.map(e => e.toLevelId)).size)
for (const gate of gates) {
const seam = `${what} seam to ${gate.toLevelId}`
const neighbour = act.get(gate.toLevelId)
expect(neighbour, `${seam}: neighbour in the act`).toBeDefined()
const shared = sharedStretch(layout, neighbour!.coord, gate.side!)
expect(shared, `${seam}: the ${gate.side} edge touches the neighbour`).not.toBeNull()
const horizontal = gate.side === 'north' || gate.side === 'south'
const offset = horizontal ? map.coord.x : map.coord.y
const from = offset + gate.span!.from
const to = offset + gate.span!.to
expect(from >= shared!.lo && to <= shared!.hi && from < to, `${seam}: opening ${from}..${to} on the shared stretch`).toBe(true)
const other = maps.get(gate.toLevelId)
if (other === undefined) continue // the camp or the Monastery Gate: not an outdoor level
const back = other.map.entrances.filter(e => e.kind === 'gate' && e.toLevelId === levelId)
expect(back, `${seam}: mirrored`).toHaveLength(1)
expect(back[0]!.side, `${seam}: mirrored`).toBe(oppositeSide(gate.side!))
const backOffset = horizontal ? other.map.coord.x : other.map.coord.y
expect([backOffset + back[0]!.span!.from, backOffset + back[0]!.span!.to], `${seam}: same opening from both sides`).toEqual([from, to])
}
if (!withScene) continue
// ---- reachability, in the collision scene the bake builds (bakeDs1Variant / buildSceneLinks) ----
const copy = BAKE_SEEDS.indexOf(seed)
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), 0x5eed_1000 + levelId * 10 + (copy < 0 ? index % 3 : copy) + 1)
const region = largestWalkableRegion(scene)
const reachable = (x: number, y: number): boolean => region[y * scene.gridWidth + x] === 1
let walkable = 0
let inRegion = 0
for (let at = 0; at < scene.blocked.length; at += 1) {
if (scene.blocked[at] === 0) walkable += 1
if (region[at] === 1) inRegion += 1
}
// One play area: the ground walled off from it is a sliver (at most ~1% measured).
expect(inRegion / walkable, `${what}: share of the walkable ground in the main region`).toBeGreaterThan(0.95)
const openings: BorderOpening[] = []
for (const gate of gates) {
const range = { from: gate.span!.from * SUB_TILES_PER_TILE, to: gate.span!.to * SUB_TILES_PER_TILE }
const opening = findBorderOpening(scene, gate.side!, region, undefined, range)
expect(opening, `${what}: walkable opening for the seam to ${gate.toLevelId}`).not.toBeNull()
expect(opening!.inset, `${what}: the seam to ${gate.toLevelId} opens at the border`).toBeLessThanOrEqual(SUB_TILES_PER_TILE)
expect(reachable(opening!.arriveX, opening!.arriveY), `${what}: arrival from ${gate.toLevelId}`).toBe(true)
openings.push(opening!)
}
for (const warp of warps) {
const { subX, subY } = warp.warp!
expect(triggerableFrom(scene, region, subX, subY, WARP_TRIGGER_SUBTILES), `${what}: warp to ${warp.toLevelId} at ${subX},${subY}`).toBe(true)
}
for (const wp of waypoints) {
expect(triggerableFrom(scene, region, wp.subX, wp.subY, WARP_TRIGGER_SUBTILES), `${what}: waypoint at ${wp.subX},${wp.subY}`).toBe(true)
}
for (const lm of map.landmarks) {
expect(nearestWalkable(scene, lm.subX, lm.subY, LANDMARK_REACH, region), `${what}: ${lm.id} at ${lm.subX},${lm.subY}`).not.toBeNull()
}
// The spawn is on the main region: from the seams' arrival points, and with nothing to go by
// (the Moo Moo Farm has no seam at all).
for (const spawn of [findIsoSpawn(scene, { entrances: openings }), findIsoSpawn(scene)]) {
expect(spawn, `${what}: spawn`).not.toBeNull()
const at = subTileAt(scene, spawn!.x, spawn!.y)
expect(at.inBounds && reachable(at.subX, at.subY), `${what}: spawn at ${at.subX},${at.subY} on the main region`).toBe(true)
}
}
}, 60_000)
})
})

View File

@ -1,11 +1,32 @@
/**
* Act I outdoor DRLG: bit-exact parity of the TypeScript port (src/game/drlg) with the native D2MOO
* oracle (tools/d2moo-oracle), and of the level maps the bake makes from it (src/game/drlg/drlg-map.ts).
*
* - The committed fixtures are the oracle's schema-3 dumps of four game seeds: 0x12345678 and the
* three seeds the bake generates Act I from (0x5eed0100..0102). The port, fed from the MPQ data
* exactly as the bake feeds it (scripts/lib/drlg-mpq-source.ts), must reproduce every stage: the act
* layout, the level grids, the rooms, every room's activation and its tile lists.
* - The oracle's dump and the port's dump then go through the same adapter; the canvases, DT1 library
* lists, entrances and landmarks must be identical, so a baked map is the map the oracle generates.
* - With the oracle binary built, one seed is re-run live and its RNG trace compared roll by roll.
* - Anti-cheat guards keep fixture data, seed literals and level-name branches out of the port.
*
* The MPQ data (samples/d2) is required: without it the suite fails instead of skipping.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import { dumpLevelToDs1 } from '../src/game/drlg/adapter.ts'
import { beforeAll, describe, expect, it } from 'vitest'
import type { Dt1 } from '../src/formats/dt1.ts'
import { parseTable } from '../src/game/acts.ts'
import { DrlgTraceWriter, dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
import {
firstDiff,
firstTraceDivergence,
@ -16,41 +37,83 @@ import {
runOracle,
} from '../scripts/lib/drlg-oracle.ts'
/** The Act I levels Levels.txt generates with the outdoor DRLG (checked against the table below). */
const ACT1_OUTDOOR_LEVELS = [2, 3, 4, 5, 6, 7, 17, 39]
const FIXTURE_DIR = resolve(import.meta.dirname, 'fixtures/d2moo-oracle')
const FIXTURE_SEEDS = [0x12345678, 0x5eed0100, 0x5eed0101, 0x5eed0102]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
function hexSeed(seed: number): string {
return seed.toString(16).padStart(8, '0')
}
function readFixture(seed: number): DrlgDump {
const doc = JSON.parse(gunzipSync(readFileSync(resolve(FIXTURE_DIR, `act1-seed-${hexSeed(seed)}.json.gz`))).toString('utf8')) as DrlgDump
delete doc.oracle
return doc
}
/** What the bake takes from a mapped level: the canvas and everything it links or places. */
function bakedPart(map: DrlgLevelMap): unknown {
return { ds1: map.ds1, dt1Names: map.dt1Names, entrances: map.entrances, landmarks: map.landmarks, stats: map.stats }
}
describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
const source = fsDrlgSource(ORACLE_DATA)
const tables = loadDrlgTables(source)
let data: DrlgMpqData
let superUniqueIds: string[]
const dt1Cache = new Map<string, Dt1>()
beforeAll(async () => {
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
if (missing.length > 0) throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 1)
superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
}, 120_000)
function mapLevel(dump: DrlgDump, levelId: number): DrlgLevelMap {
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
return buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
}
it('takes the outdoor level list from Levels.txt', () => {
expect(act1OutdoorLevelIds(data.tables)).toEqual(ACT1_OUTDOOR_LEVELS)
})
for (const seed of FIXTURE_SEEDS) {
const hex = seed.toString(16).padStart(8, '0')
it(`matches committed oracle fixture for seed 0x${hex} across all stages including room activation and adapted Ds1 canvas`, () => {
const fixturePath = resolve(FIXTURE_DIR, `act1-seed-${hex}.json.gz`)
const expected = JSON.parse(gunzipSync(readFileSync(fixturePath)).toString('utf8')) as DrlgDump
delete expected.oracle
const actual = dumpAct1(createDrlgEnv(source, tables), seed, ACT1_OUTDOOR_LEVELS, {
const hex = hexSeed(seed)
it(`seed 0x${hex}: the MPQ-fed port reproduces every oracle stage, and both map to identical canvases`, () => {
const expected = readFixture(seed)
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT1_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: false,
})
const diff = firstDiff(expected, actual, '$')
if (diff) {
throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
}
expect(diff).toBeNull()
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
for (let i = 0; i < ACT1_OUTDOOR_LEVELS.length; i += 1) {
const expectedCanvas = dumpLevelToDs1(expected.act, expected.levels[i]!, tables, { seed })
const actualCanvas = dumpLevelToDs1(actual.act, actual.levels[i]!, tables, { seed })
const canvasDiff = firstDiff(expectedCanvas, actualCanvas, `$.canvas[L${ACT1_OUTDOOR_LEVELS[i]}]`)
if (canvasDiff) {
throw new Error(`Canvas mismatch on seed 0x${hex}: ${formatDiff(canvasDiff)}`)
for (const levelId of ACT1_OUTDOOR_LEVELS) {
const oracleMap = mapLevel(expected, levelId)
const portMap = mapLevel(actual, levelId)
const canvasDiff = firstDiff(bakedPart(oracleMap), bakedPart(portMap), `$.L${levelId}`)
if (canvasDiff) throw new Error(`Canvas mismatch on seed 0x${hex}: ${formatDiff(canvasDiff)}`)
// The canvas is the level's tile rectangle (plus the closing row/column a DS1 has), every tile
// D2 placed is on it, and every level link and warp of the level became an entrance.
const { coord, ds1, stats } = portMap
expect(ds1.width).toBe(coord.width + 1)
expect(ds1.height).toBe(coord.height + 1)
expect(stats.floors).toBeGreaterThan(0)
expect(stats.walls).toBeGreaterThan(0)
const level = actual.levels.find(l => l.id === levelId)!
const orths = level.levelGrid!.roomData.length
const warpUnits = level.activation.reduce((n, room) => n + room.mapTiles.units.filter(u => u[0] === 5).length, 0)
expect(portMap.entrances.filter(e => e.kind === 'gate')).toHaveLength(orths)
expect(portMap.entrances.filter(e => e.kind === 'preset')).toHaveLength(warpUnits)
}
expect(actualCanvas.level.width).toBeGreaterThan(0)
expect(actualCanvas.level.height).toBeGreaterThan(0)
}
})
}, 60_000)
}
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle + RNG trace on seed 0x12345678', () => {
@ -64,8 +127,9 @@ describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
trace: true,
})
delete oracleRun.doc.oracle
const source = fsDrlgSource(ORACLE_DATA)
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(source, tables), seed, ACT1_OUTDOOR_LEVELS, {
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT1_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: false,
trace,
@ -76,7 +140,7 @@ describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
}
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
})
}, 60_000)
it('enforces anti-cheat guards on src/game/drlg/* (no fixture reads, no seed literal branches, no levelName conditionals)', () => {
const drlgDir = resolve(import.meta.dirname, '../src/game/drlg')
@ -88,9 +152,7 @@ describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
expect(content, `${file} must not reference act1-seed-`).not.toContain('act1-seed-')
expect(content, `${file} must not hardcode fixture seed 0x12345678`).not.toMatch(/0x12345678/i)
expect(content, `${file} must not hardcode fixture seed 0x5eed`).not.toMatch(/0x5eed/i)
if (file !== 'adapter.ts') {
expect(content, `${file} must not branch on levelName`).not.toContain('levelName')
}
}
})
})

View File

@ -15,9 +15,11 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => {
expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask")
expect(exports).not.toContain("DRLGROOMTILE_ApplyTransitions")
// The genuine custom functions should remain exported as local names
expect(typeof (wilderness as Record<string, unknown>).computeNeighborMask).toBe("function")
expect(typeof (wilderness as Record<string, unknown>).applyGroundTransitions).toBe("function")
// The invented 8-neighbour ground-transition pass (computeNeighborMask /
// applyGroundTransitions) was removed with the Act I DRLG port: Act I
// outdoor maps now come from the 1:1 D2MOO port in src/game/drlg.
expect((wilderness as Record<string, unknown>).computeNeighborMask).toBeUndefined()
expect((wilderness as Record<string, unknown>).applyGroundTransitions).toBeUndefined()
})
test("src/game/wilderness.ts keeps single export of DRLGOUTDOORS_SpawnAct12Waypoint", () => {
@ -71,9 +73,9 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => {
const levelLinksSource = readFileSync(resolve(__dirname, "../src/game/level-links.ts"), "utf-8")
expect(levelLinksSource).not.toMatch(/export\s+(?:const|function|let|var)\s+DRLGOUTDOORS_SpawnAct12Waypoint\b/)
// Ensure wilderness.ts has doc comments clarifying custom web port algorithms
// The invented (non-Blizzard) ground-transition helpers must not come back.
const wildernessSource = readFileSync(resolve(__dirname, "../src/game/wilderness.ts"), "utf-8")
expect(wildernessSource).toMatch(/NOT a Blizzard original[\s\S]*?function computeNeighborMask/)
expect(wildernessSource).toMatch(/NOT a Blizzard original[\s\S]*?function applyGroundTransitions/)
expect(wildernessSource).not.toMatch(/function computeNeighborMask\b/)
expect(wildernessSource).not.toMatch(/function applyGroundTransitions\b/)
})
})

View File

@ -1746,7 +1746,7 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label
}
})
it('verifies all 365 baked map packs (495 SceneWarp and 172 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => {
it('verifies all 365 baked map packs (495 SceneWarp and 166 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => {
const baseDir = path.resolve(process.cwd(), 'samples/d2-packs')
const indexPath = path.join(baseDir, 'index.json')
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
@ -1810,7 +1810,12 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label
expect(warpCount).toBe(495)
expect(nonZeroBoxWarps).toBe(464)
expect(zeroBoxWarps).toBe(31)
expect(entranceCount).toBe(172)
// 166 since the Act I outdoor levels come from the DRLG port: D2 has no
// Stony Field <-> Dark Wood seam (DrlgOutPlace.cpp gAct1WildernessDrlgLink /
// gAct1MonasteryDrlgLink put them in separate groups, joined only through
// the Underground Passage), so the 2 seam entrances the old generator
// invented in each of the 3 act copies are gone.
expect(entranceCount).toBe(166)
})
it('ignores unresolved warps and entrances (toLevelId <= 0) for overhead badges, hover, left-click, and Telekinesis', () => {

View File

@ -10,7 +10,7 @@
* to floor tiles, without wall vertical block adjustment (`minBlockY + 80`).
* 3. `IsoMapScene` exposes `shadows: readonly IsoDraw[]`, sorted by isometric depth.
* 4. Sub-tile collision flags of shadow layers are unioned into `collisionMasks` and `blocked`.
* 5. Blood Moor and Act 1 wilderness levels include Tree Fill and shadow-bearing presets.
* 5. Real Act 1 presets (Ruin2.ds1) carry shadow tiles that resolve to DT1 type 13.
*/
import { describe, it, expect } from 'vitest'
@ -20,11 +20,6 @@ import {
ORTHO_CELL_WIDTH,
} from '../src/game/d2map.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import {
BLOOD_MOOR_RIVER_PRESETS,
BLOOD_MOOR_POND_PRESETS,
SPECIAL_PRESETS_BY_LEVEL,
} from '../src/game/wilderness.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { fileSource } from '../src/mpq/file-source.ts'
@ -286,18 +281,6 @@ describe('Shadow Layer (DT1 Type 13) Rendering & Parity', () => {
}
})
it('Blood Moor and Act 1 wilderness preset lists include shadow-bearing presets (Ruin, Tree Fill)', () => {
expect(BLOOD_MOOR_RIVER_PRESETS).toContain('Act 1 - Ruin')
expect(BLOOD_MOOR_POND_PRESETS).toContain('Act 1 - Ruin')
expect(SPECIAL_PRESETS_BY_LEVEL[2]).toContain('Act 1 - Ruin')
expect(SPECIAL_PRESETS_BY_LEVEL[3]).toContain('Act 1 - Ruin')
expect(SPECIAL_PRESETS_BY_LEVEL[4]).toContain('Act 1 - Ruin')
expect(SPECIAL_PRESETS_BY_LEVEL[5]).toContain('Act 1 - Tree Fill')
expect(SPECIAL_PRESETS_BY_LEVEL[5]).toContain('Act 1 - Ruin')
expect(SPECIAL_PRESETS_BY_LEVEL[6]).toContain('Act 1 - Ruin')
expect(SPECIAL_PRESETS_BY_LEVEL[7]).toContain('Act 1 - Ruin')
})
const mpqDir = existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq')
? '/usr/local/google/home/taodao/d2-data'
: existsSync('samples/d2/d2data.mpq')

View File

@ -189,7 +189,8 @@ describe('Adversarial Stress Test: resolveWorldOffsets across 100 Seeds for Acts
}
expect(failures).toEqual([])
expect(pairsChecked).toBe(1900)
// 18 pairs (Act 1: 7, Act 2: 5, Act 4: 3, Act 5: 3) x 100 seeds.
expect(pairsChecked).toBe(1800)
})
it('Act 3 Seamless Adjacency: contiguous pairs in SEAMLESS_ADJACENCY share exact boundary coordinates without tears or gaps', () => {

View File

@ -1,728 +0,0 @@
/**
* Wilderness D2MOO Ground Truth Parity Test Suite (Issue #455).
*
* Verifies that outdoor wilderness level generation adheres with 100% fidelity
* to Diablo II v1.13c ground truth as established by D2MOO C++ DRLG engine
* (`D2Common/src/Drlg/`) and native binary execution (`d2moo_mapgen`).
*
* Multi-Tier Parity Coverage:
* - Tier 1: Dynamic Level Dimensions (96x56 horizontal vs 56x96 vertical) & Topology (R1)
* - Tier 2: Outer River Placement, Dual Columns (x=80, 88), and Boundary Suppression (R2)
* - Tier 3: Town Transition (TownSTrans.ds1 56x8) Anchored to Eastern River Edge (R4)
* - Tier 4: Unconditional Landmark Presets & FarAway Anti-Overlap Metric (R4)
* - Tier 5: 4-Pass Secondary Border Evolution & Interior 2x3 Forest Closures (Bord9..12) (R3)
* - Tier 6: Orthogonal Polyline Dirt Paths & Width-2 Bresenham Rasterization (R5)
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
type WildernessResult,
} from '../src/game/wilderness.ts'
/* ------------------------------------------------------------------------- *
* D2MOO Data Contracts & Types
* ------------------------------------------------------------------------- */
export interface OutdoorCellJson {
readonly x: number
readonly y: number
readonly fPacked: number
readonly fStyle: number
readonly fSeq: number
readonly wPacked: number
readonly wStyle: number
readonly wSeq: number
readonly wType: number
readonly dirt: number
}
export interface D2MooRoomJson {
readonly index: number
readonly type: number
readonly tileX: number
readonly tileY: number
readonly tileW: number
readonly tileH: number
readonly mapPtr: number
readonly mapX: number
readonly mapY: number
readonly mapW: number
readonly mapH: number
readonly lvlPrestId: number
readonly pickedFile: number
readonly ds1File: string
readonly ds1Width: number
readonly ds1Height: number
readonly doorMask: number
readonly orthCount: number
readonly outdoorCells: readonly OutdoorCellJson[]
}
export interface D2MooLevelJson {
readonly levelId: number
readonly levelName: string
readonly act: number
readonly drlgType: number
readonly levelType: number
readonly seed: number
readonly posX: number
readonly posY: number
readonly width: number
readonly height: number
readonly roomCount: number
readonly rooms: readonly D2MooRoomJson[]
}
/* ------------------------------------------------------------------------- *
* Dual-Mode Execution & Fixture Loader
* ------------------------------------------------------------------------- */
const D2MOO_BIN = process.env.D2MOO_BIN ?? '/usr/local/google/home/taodao/tmp/d2moo_runner/d2moo_mapgen'
const FIXTURES_DIR = 'tests/fixtures/d2moo'
const hasD2Archives = existsSync('samples/d2/d2data.mpq')
/**
* Loads a D2MOO level layout either by executing `d2moo_mapgen` dynamically
* or by reading the pre-recorded golden fixture from `tests/fixtures/d2moo/`.
*/
export function loadOrRunD2MooLevel(levelId: number, seed: number | string): D2MooLevelJson {
const seedNum = typeof seed === 'number' ? seed : (seed.startsWith('0x') ? parseInt(seed, 16) : parseInt(seed, 10))
const seedHex = `0x${seedNum.toString(16)}`
const seedDec = seedNum.toString(10)
// 1. If native binary is present and not explicitly bypassed, run dynamically
if (existsSync(D2MOO_BIN) && process.env.D2MOO_FORCE_FIXTURE !== '1') {
try {
const tmpOut = join(tmpdir(), `d2moo_parity_${levelId}_${seedNum.toString(16)}.json`)
execFileSync(D2MOO_BIN, [String(levelId), seedHex, '0', tmpOut], { stdio: 'ignore' })
if (existsSync(tmpOut)) {
return JSON.parse(readFileSync(tmpOut, 'utf8')) as D2MooLevelJson
}
} catch {
// Fallback to pre-recorded fixture
}
}
// 2. Fallback to pre-recorded golden JSON fixture
const candidateNames = [
`level${levelId}_seed_0x${seedNum.toString(16)}.json`,
`level${levelId}_seed_${seedDec}.json`,
`level${levelId}_seed_${seedNum.toString(16)}.json`,
`level_${levelId}_seed_${seedNum.toString(16)}.json`,
]
for (const name of candidateNames) {
const p = join(FIXTURES_DIR, name)
if (existsSync(p)) {
return JSON.parse(readFileSync(p, 'utf8')) as D2MooLevelJson
}
}
throw new Error(`Neither executable d2moo_mapgen nor golden fixture found for Level ${levelId} Seed ${seedHex}`)
}
/**
* Extracts distinct preset stamp instances (deduplicating multi-room presets sharing mapPtr).
*/
export function getDistinctPresetStamps(level: D2MooLevelJson): Array<{
file: string
x: number
y: number
w: number
h: number
}> {
const seenMapPtrs = new Set<number>()
const stamps: Array<{ file: string; x: number; y: number; w: number; h: number }> = []
for (const room of level.rooms) {
if (!room.ds1File) continue
if (seenMapPtrs.has(room.mapPtr)) continue
seenMapPtrs.add(room.mapPtr)
const file = room.ds1File.split('/').pop() ?? room.ds1File
stamps.push({
file,
x: room.mapX - level.posX,
y: room.mapY - level.posY,
w: room.mapW,
h: room.mapH,
})
}
return stamps
}
/**
* Extracts absolute world tile coordinates of all dirt road cells in the level.
*/
export function getDirtRoadCells(level: D2MooLevelJson): Array<{ x: number; y: number }> {
const cells: Array<{ x: number; y: number }> = []
for (const room of level.rooms) {
for (const cellRecord of room.outdoorCells) {
if (cellRecord.dirt !== 0) {
cells.push({
x: room.tileX - level.posX + cellRecord.x,
y: room.tileY - level.posY + cellRecord.y,
})
}
}
}
return cells
}
/**
* Analyzes connectivity of road cells via 8-way BFS flood-fill.
*/
export function evaluateRoadConnectivity(cells: Array<{ x: number; y: number }>): {
totalCells: number
componentCount: number
largestComponentSize: number
} {
const cellSet = new Set(cells.map(c => `${c.x},${c.y}`))
const visited = new Set<string>()
const components: string[][] = []
for (const key of cellSet) {
if (visited.has(key)) continue
const queue = [key]
visited.add(key)
const comp = [key]
while (queue.length > 0) {
const current = queue.shift()!
const [cx, cy] = current.split(',').map(Number)
for (const [dx, dy] of [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
]) {
const neighbor = `${cx + dx},${cy + dy}`
if (cellSet.has(neighbor) && !visited.has(neighbor)) {
visited.add(neighbor)
queue.push(neighbor)
comp.push(neighbor)
}
}
}
components.push(comp)
}
return {
totalCells: cells.length,
componentCount: components.length,
largestComponentSize: components[0]?.length ?? 0,
}
}
/* ========================================================================= *
* SUITE 1: D2MOO v1.13c Ground Truth Oracle & Fixture Invariants
* ========================================================================= */
describe('Part 1: D2MOO Ground Truth Fixture & Runner Verification (Issue #455)', () => {
// --- Tier 1: Dimensions & Orientations ---
describe('Tier 1: Dynamic Level Dimensions & Topology Invariants (R1)', () => {
it('verifies horizontal 96x56 Moor layout for Seed 0x12345678', () => {
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
expect(d2moo.width).toBe(96)
expect(d2moo.height).toBe(56)
expect(d2moo.width / 8).toBe(12)
expect(d2moo.height / 8).toBe(7)
expect(d2moo.roomCount).toBe(82)
})
it('verifies horizontal 96x56 Moor layout for Seed 12345', () => {
const d2moo = loadOrRunD2MooLevel(2, 12345)
expect(d2moo.width).toBe(96)
expect(d2moo.height).toBe(56)
expect(d2moo.width / 8).toBe(12)
expect(d2moo.height / 8).toBe(7)
expect(d2moo.roomCount).toBe(84)
})
it('verifies vertical 56x96 Moor layout for Seed 1', () => {
const d2moo = loadOrRunD2MooLevel(2, 1)
expect(d2moo.width).toBe(56)
expect(d2moo.height).toBe(96)
expect(d2moo.width / 8).toBe(7)
expect(d2moo.height / 8).toBe(12)
expect(d2moo.roomCount).toBe(79)
})
it('verifies vertical 56x96 Moor layout for Seed 42', () => {
const d2moo = loadOrRunD2MooLevel(2, 42)
expect(d2moo.width).toBe(56)
expect(d2moo.height).toBe(96)
expect(d2moo.width / 8).toBe(7)
expect(d2moo.height / 8).toBe(12)
expect(d2moo.roomCount).toBe(82)
})
it('verifies Cold Plains (Level 3) fixed 80x80 layout for Seed 1', () => {
const d2moo = loadOrRunD2MooLevel(3, 1)
expect(d2moo.width).toBe(80)
expect(d2moo.height).toBe(80)
expect(d2moo.roomCount).toBe(97)
})
it('verifies Stony Field (Level 4) fixed 80x80 layout for Seed 1', () => {
const d2moo = loadOrRunD2MooLevel(4, 1)
expect(d2moo.width).toBe(80)
expect(d2moo.height).toBe(80)
expect(d2moo.roomCount).toBe(91)
})
})
// --- Tier 2: Outer River Placement ---
describe('Tier 2: Outer River Placement & Boundary Suppression (R2)', () => {
it('spawns dual-column river on eastern boundary (x=80, x=88) for Seed 0x12345678', () => {
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
const riverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('River'))
expect(riverRooms.length).toBeGreaterThanOrEqual(12)
const uRiverRooms = riverRooms.filter(r => r.ds1File.includes('URiverC'))
const lRiverRooms = riverRooms.filter(r => r.ds1File.includes('LRiverC'))
expect(uRiverRooms.length).toBeGreaterThanOrEqual(6)
expect(lRiverRooms.length).toBeGreaterThanOrEqual(6)
// Column x = 80 (block 10, nGridWidth - 2) for URiverC
for (const room of uRiverRooms) {
expect(room.mapX - d2moo.posX).toBe(80)
}
// Column x = 88 (block 11, nGridWidth - 1) for LRiverC
for (const room of lRiverRooms) {
expect(room.mapX - d2moo.posX).toBe(88)
}
// Outer boundary tree wall suppression: no Bord*.ds1 rooms exist at x = 88 where river flows
const eastBorders = d2moo.rooms.filter(r => {
const file = r.ds1File?.split('/').pop() ?? ''
const relX = r.mapX - d2moo.posX
return file.startsWith('Bord') && relX === 88
})
expect(eastBorders.length).toBe(0)
})
it('spawns dual-column river on eastern boundary (x=80, x=88) for Seed 12345', () => {
const d2moo = loadOrRunD2MooLevel(2, 12345)
const uRiverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('URiverC'))
const lRiverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('LRiverC'))
expect(uRiverRooms.length).toBeGreaterThanOrEqual(6)
expect(lRiverRooms.length).toBeGreaterThanOrEqual(6)
for (const room of uRiverRooms) expect(room.mapX - d2moo.posX).toBe(80)
for (const room of lRiverRooms) expect(room.mapX - d2moo.posX).toBe(88)
})
})
// --- Tier 3: Town Transition Alignment ---
describe('Tier 3: Town Transition Alignment & River Edge Anchoring (R4)', () => {
it('places TownSTrans.ds1 (56x8) anchored to East river at (40, 0) for Seed 0x12345678', () => {
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
const townRoom = d2moo.rooms.find(r => r.ds1File && r.ds1File.includes('TownSTrans'))
expect(townRoom).toBeDefined()
expect(townRoom!.mapX - d2moo.posX).toBe(40)
expect(townRoom!.mapY - d2moo.posY).toBe(0)
expect(townRoom!.mapW).toBe(56)
expect(townRoom!.mapH).toBe(8)
// 40 + 56 = 96: transition extends exactly to the eastern river edge (x = 80..96)
expect(townRoom!.mapX - d2moo.posX + townRoom!.mapW).toBe(d2moo.width)
})
})
// --- Tier 4: Landmark Presets & FarAway Anti-Overlap Metric ---
describe('Tier 4: Complete Landmark Presets & FarAway Distribution (R4)', () => {
it('unconditionally places all 5 canonical landmark categories in Blood Moor (Seed 0x12345678)', () => {
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
const uniquePresets = new Set(d2moo.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
expect(uniquePresets.has('DenEnt.ds1')).toBe(true)
expect(uniquePresets.has('Pond1.ds1')).toBe(true)
expect(uniquePresets.has('Cott1.ds1')).toBe(true)
expect(uniquePresets.has('Circle.ds1')).toBe(true)
expect(uniquePresets.has('Clover.ds1')).toBe(true)
})
it('verifies zero spatial overlap across all distinct preset stamps', () => {
for (const [lvl, s] of [
[2, 0x12345678],
[2, 12345],
[2, 1],
[2, 42],
[3, 1],
[4, 1],
] as const) {
const d2moo = loadOrRunD2MooLevel(lvl, s)
const stamps = getDistinctPresetStamps(d2moo)
for (let i = 0; i < stamps.length; i += 1) {
for (let j = i + 1; j < stamps.length; j += 1) {
const a = stamps[i]!
const b = stamps[j]!
const xOverlap = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x))
const yOverlap = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y))
expect(
xOverlap > 0 && yOverlap > 0,
`Overlap detected in Level ${lvl} Seed ${s} between ${a.file} and ${b.file}`,
).toBe(false)
}
}
}
})
it('verifies FarAway metric (|dx| + 2*|dy|) places Den of Evil distant from Town Gate', () => {
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
const townRoom = d2moo.rooms.find(r => r.ds1File.includes('TownSTrans'))!
const denRoom = d2moo.rooms.find(r => r.ds1File.includes('DenEnt'))!
const townX = townRoom.mapX - d2moo.posX
const townY = townRoom.mapY - d2moo.posY
const denX = denRoom.mapX - d2moo.posX
const denY = denRoom.mapY - d2moo.posY
// FarAway Manhattan metric: |dx| + 2*|dy|
const distance = Math.abs(denX - townX) + 2 * Math.abs(denY - townY)
expect(distance).toBeGreaterThanOrEqual(48)
})
it('verifies canonical presets in Cold Plains (Level 3) and Stony Field (Level 4)', () => {
const l3 = loadOrRunD2MooLevel(3, 1)
const l3Presets = new Set(l3.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
expect(l3Presets.has('CaveDr1.ds1')).toBe(true)
expect(l3Presets.has('FallCmpBish.ds1')).toBe(true)
expect(l3Presets.has('Cott1A.ds1')).toBe(true)
expect(l3Presets.has('Circle.ds1')).toBe(true)
expect(l3Presets.has('Clover.ds1')).toBe(true)
const l4 = loadOrRunD2MooLevel(4, 1)
const l4Presets = new Set(l4.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
expect(l4Presets.has('cairn2.ds1')).toBe(true)
expect(l4Presets.has('clfcave.ds1')).toBe(true)
expect(l4Presets.has('tome.ds1')).toBe(true)
expect(l4Presets.has('Corral1.ds1')).toBe(true)
expect(l4Presets.has('FallCmp.ds1')).toBe(true)
})
})
// --- Tier 5: 4-Pass Secondary Borders & 2x3 Forest Closures ---
describe('Tier 5: 4-Pass Secondary Border Evolution & 2x3 Closures (R3)', () => {
it('spawns Bord5..8 outer peninsulas and Bord9..12 interior 2x3 forest closures', () => {
for (const [lvl, s] of [
[2, 0x12345678],
[2, 12345],
[2, 1],
[2, 42],
[3, 1],
[4, 1],
] as const) {
const d2moo = loadOrRunD2MooLevel(lvl, s)
const files = d2moo.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean) as string[]
const peninsulas = files.filter(f => /^Bord[5678]\.ds1$/i.test(f))
const closures = files.filter(f => /^Bord(9|10|11|12)\.ds1$/i.test(f))
expect(peninsulas.length, `Level ${lvl} Seed ${s} missing Bord5..8 peninsulas`).toBeGreaterThan(0)
expect(closures.length, `Level ${lvl} Seed ${s} missing Bord9..12 closures`).toBeGreaterThanOrEqual(4)
}
})
})
// --- Tier 6: Dirt Path Connectivity ---
describe('Tier 6: Orthogonal Polyline Dirt Paths & Connectivity (R5)', () => {
it('generates 100% contiguous dirt road network connecting gate, den, and exits', () => {
for (const s of [0x12345678, 12345, 1, 42] as const) {
const d2moo = loadOrRunD2MooLevel(2, s)
const dirtCells = getDirtRoadCells(d2moo)
expect(dirtCells.length, `Level 2 Seed ${s} must have dirt road cells`).toBeGreaterThanOrEqual(100)
const connectivity = evaluateRoadConnectivity(dirtCells)
expect(
connectivity.componentCount,
`Level 2 Seed ${s} road cells should form a single connected component`,
).toBe(1)
expect(connectivity.largestComponentSize).toBe(dirtCells.length)
}
})
})
})
/* ========================================================================= *
* SUITE 2: Wilderness Generator Ground Truth Parity (Issue #455 Milestones)
* ========================================================================= */
describe.skipIf(!hasD2Archives)('Part 2: DRLG Wilderness Generator Parity vs D2MOO (Milestones M1..M6)', () => {
let act1Pieces: WildernessPiece[]
let act1Subs: WildernessSubstitution[]
const WILDERNESS_PIECE_FAMILIES = [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
]
beforeAll(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 {
// Skip optional archive if missing
}
}
const actTables = await loadActTables(archives)
const lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const val = cell(table, row, `File${String(slot)}`)
if (val === '' || val === '0') continue
levels.push(await loadDs1(val))
}
return levels
}
// Load Act 1 wilderness preset pieces
act1Pieces = []
for (const row of actTables.lvlprest.rows) {
const name = cell(actTables.lvlprest, row, 'Name')
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
const levels = await rowDs1s(actTables.lvlprest, row)
if (levels.length === 0) continue
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
}
// Load Act 1 substitutions (SubTypes 0, 1, 6)
act1Subs = []
for (const row of lvlsubTable.rows) {
const subType = Number(cell(lvlsubTable, row, 'Type'))
if (subType !== 0 && subType !== 1 && subType !== 6) continue
const file = cell(lvlsubTable, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
act1Subs.push({
name: cell(lvlsubTable, row, 'Name'),
type: subType,
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsubTable, row, 'BordType')),
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
levels,
})
}
})
// --- Milestone M1: Dynamic Dimensions ---
describe('Milestone M1 Parity: Dynamic Level Dimensions (R1)', () => {
it('generates 96x56 horizontal Moor for Seed 0x12345678', () => {
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
expect(result.level.width).toBe(d2moo.width)
expect(result.level.height).toBe(d2moo.height)
})
it('generates 56x96 vertical Moor for Seed 1', () => {
const d2moo = loadOrRunD2MooLevel(2, 1)
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 1,
pieces: act1Pieces,
substitutions: act1Subs,
})
expect(result.level.width).toBe(d2moo.width)
expect(result.level.height).toBe(d2moo.height)
})
})
// --- Milestone M2: Outer River Placement ---
describe('Milestone M2 Parity: Outer River Placement & Boundary Replacement (R2)', () => {
it('spawns river along eastern boundary replacing outer tree border for Seed 0x12345678', () => {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
// River tiles on east edge: x in [80..95] contains water floors
let hasWaterTile = false
const maxX = result.level.width
const maxY = result.level.height
for (let y = 8; y < maxY - 8; y += 1) {
for (let x = Math.max(0, maxX - 16); x < maxX; x += 1) {
const row = result.level.cells[y]
if (row?.[x]?.floors.some(f => f.style === 2)) {
hasWaterTile = true
break
}
}
if (hasWaterTile) break
}
expect(hasWaterTile).toBe(true)
})
})
// --- Milestone M4: Town Transition Alignment ---
describe('Milestone M4 Parity: Town Transition Alignment (R4)', () => {
it('aligns TownSTrans gate to river edge for Seed 0x12345678', () => {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
const notes = result.stats.notes ?? []
const hasTownTrans = notes.some(n => /Town.*Trans/i.test(n))
expect(hasTownTrans).toBe(true)
})
})
// --- Milestone M4: Unconditional Landmarks ---
describe('Milestone M4 Parity: Unconditional Landmark Presets & FarAway (R4)', () => {
it('places all 5 canonical landmark categories in Blood Moor (Seed 0x12345678)', () => {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
const notes = (result.stats.notes ?? []).join('\n')
expect(/DOE|Den/i.test(notes)).toBe(true)
expect(/Pond/i.test(notes)).toBe(true)
expect(/Cottage|Bivouac/i.test(notes)).toBe(true)
expect(/Circle|Stone/i.test(notes)).toBe(true)
expect(/Clover/i.test(notes)).toBe(true)
})
})
// --- Milestone M3: Secondary Borders & 2x3 Forest Closures ---
describe('Milestone M3 Parity: Secondary Borders & 2x3 Forest Closures (R3)', () => {
it('spawns Bord5..8 peninsulas and Bord9..12 interior 2x3 closures for Seed 0x12345678', () => {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
const notes = (result.stats.notes ?? []).join('\n')
// Must record secondary border peninsulas and interior 2x3 closures
expect(/Bord[5678]|peninsula/i.test(notes)).toBe(true)
expect(/Bord(9|10|11|12)|2x3|closure/i.test(notes)).toBe(true)
})
})
// --- Milestone M5: Orthogonal Dirt Road Rasterization ---
describe('Milestone M5 Parity: Orthogonal Dirt Paths & Width-2 Bresenham (R5)', () => {
it('generates orthogonal width-2 dirt road without spline loop deviations', () => {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
// Dirt road floor tiles in Act 1 use style 0 with sequence 1..46 (byte_6FDCF958 LUT)
let dirtTileCount = 0
for (const row of result.level.cells) {
for (const cellRecord of row) {
if (cellRecord.floors.some(f => (f.style === 0 && f.sequence >= 1 && f.sequence <= 46) || (f.style === 1 && f.prop1 === 194))) {
dirtTileCount += 1
}
}
}
// Parity expectation: road connects town gate to den and exit with width-2 footprint (>= 100 tiles)
expect(dirtTileCount).toBeGreaterThanOrEqual(100)
})
})
})

View File

@ -102,102 +102,11 @@ describe("Wilderness Features & Border Rules", () => {
expect(isClosedBorder(pickedClosed, "south")).toBe(true)
})
test("SPECIAL_PRESETS_BY_LEVEL contains stone walls and river presets", () => {
const bloodMoorPresets = SPECIAL_PRESETS_BY_LEVEL[2]
expect(bloodMoorPresets).toBeDefined()
expect(bloodMoorPresets).toContain("Act 1 - Corral Fill")
expect(bloodMoorPresets).toContain("Act 1 - Fence Fill 4")
expect(bloodMoorPresets).toContain("Act 1 - River Upper")
expect(bloodMoorPresets).toContain("Act 1 - Bridge")
const cowPresets = SPECIAL_PRESETS_BY_LEVEL[39]
expect(cowPresets).toBeDefined()
expect(cowPresets).toContain("Act 1 - Corral Fill")
expect(cowPresets).toContain("Act 1 - Fence Fill 4")
expect(cowPresets).toContain("Act 1 - Fence Fill 5")
expect(cowPresets).toContain("Act 1 - Bivouac")
expect(cowPresets).toContain("Act 1 - Pond")
})
test("generateWilderness generates Level 2 with river or pond deterministically by seed", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [closedDs1],
test("SPECIAL_PRESETS_BY_LEVEL has no Act I outdoor entries (the DRLG port places those presets)", () => {
for (const levelId of [2, 3, 4, 5, 6, 7, 17, 39]) {
expect(SPECIAL_PRESETS_BY_LEVEL[levelId]).toBeUndefined()
}
const riverPiece: WildernessPiece = {
name: "Act 1 - River Upper",
border: false,
levels: [closedDs1],
}
const pondPiece: WildernessPiece = {
name: "Act 1 - Pond",
border: false,
levels: [closedDs1],
}
const corralPiece: WildernessPiece = {
name: "Act 1 - Corral Fill",
border: false,
levels: [closedDs1],
}
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 12345,
pieces: [piece, riverPiece, pondPiece, corralPiece],
substitutions: [],
})
expect(result.stats.blockGrid).toEqual({ width: 10, height: 10 })
expect(result.level.width).toBe(80)
expect(result.level.height).toBe(80)
})
test("Act 1 Wilderness generates interior stone walls and dirt road network", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [closedDs1],
}
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 99999,
pieces: [piece],
substitutions: [],
})
let stoneWallCount = 0
let dirtRoadCount = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
stoneWallCount += 1
}
if (cell.floors.some(f => (f.style === 0 || f.style === 5) && f.prop1 === 194)) {
dirtRoadCount += 1
}
}
}
expect(stoneWallCount).toBe(0)
expect(dirtRoadCount).toBeGreaterThan(30)
expect(SPECIAL_PRESETS_BY_LEVEL[41]).toContain("Act 2 - Desert Tomb 1")
})
test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => {
@ -315,101 +224,6 @@ describe("Wilderness Features & Border Rules", () => {
expect(result.level.objects.length).toBe(6)
})
test("Act 1 Blood Moor (Level 2) connects South/East border gates with 3-tile road and uses authentic stone wall sequences", () => {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
const presetDs1 = makeMockDs1(16, 16, false)
const riverDs1 = makeMockDs1(8, 8, false)
const pieces: WildernessPiece[] = [
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [borderDs1] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [borderDs1] },
{ name: "Act 1 - DOE Entrance", border: false, levels: [presetDs1] },
{ name: "Act 1 - River Upper", border: false, levels: [riverDs1, riverDs1, riverDs1] },
{ name: "Act 1 - River Lower", border: false, levels: [riverDs1, riverDs1, riverDs1] },
{ name: "Act 1 - Bridge", border: false, levels: [riverDs1, riverDs1] },
{ name: "Act 1 - Cottages 1", border: false, levels: [presetDs1] },
]
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: -1,
subTheme: -1,
seed: 1234,
pieces,
substitutions: [],
specialPresets: [
"Act 1 - DOE Entrance",
"Act 1 - River Upper",
"Act 1 - River Lower",
"Act 1 - Bridge",
"Act 1 - Cottages 1",
],
})
// 1. Verify 3-tile wide main highway at central crossroads (44, 44)
expect(result.level.cells[44]![44]!.floors[0]!.style).toBe(0)
expect(result.level.cells[44]![43]!.floors[0]!.style).toBe(0)
expect(result.level.cells[44]![45]!.floors[0]!.style).toBe(0)
// 2. Verify South border gate road connection at (44, 75) and East border gate connection at (75, 44)
expect(result.level.cells[75]![44]!.floors[0]!.style).toBe(0)
expect(result.level.cells[44]![75]!.floors[0]!.style).toBe(0)
// 3. Verify no artificial interior stone wall segments (style=2, prop1=129) are stamped (Issue #65)
let foundContinuousEW = false
let foundEndCapEW = false
for (let y = 10; y < 70; y += 1) {
for (let x = 10; x < 70; x += 1) {
const wall = result.level.cells[y]![x]!.walls.find(w => w.style === 2 && w.prop1 === 129)
if (wall && wall.type === 2) {
if (wall.sequence === 0) foundContinuousEW = true
if (wall.sequence === 1 || wall.sequence === 2) foundEndCapEW = true
}
}
}
expect(foundContinuousEW).toBe(false)
expect(foundEndCapEW).toBe(false)
})
test("records unresolved presets and notes when requested preset piece is missing", () => {
const borderDs1 = makeMockDs1(8, 8, false)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 9999,
pieces: [piece], // Missing Den of Evil Entrance preset!
substitutions: [],
})
const unresolved = result.stats.unresolved as string[]
const notes = result.stats.notes as string[]
expect(unresolved.length).toBeGreaterThan(0)
expect(unresolved.some((u: string) => u.includes('Act 1 - DOE Entrance'))).toBe(true)
expect(notes.some((n: string) => n.includes('Den of Evil Entrance'))).toBe(true)
})
test("resolveUnsetSize dynamically derives vertical corridor dimensions for Level 111 and 112", () => {
const entranceDs1 = makeMockDs1(17, 33, false)
const exitDs1 = makeMockDs1(17, 33, false)

View File

@ -11,31 +11,16 @@ import {
generateWilderness,
CANONICAL_GROUND_TILES,
CANONICAL_GROUND_POOLS,
computeNeighborMask,
NEIGHBOR_MASK_NE,
NEIGHBOR_MASK_E,
NEIGHBOR_MASK_SE,
NEIGHBOR_MASK_N,
NEIGHBOR_MASK_S,
NEIGHBOR_MASK_NW,
NEIGHBOR_MASK_W,
NEIGHBOR_MASK_SW,
NEIGHBOR_OFFSETS,
DIRT_PATH_TILE_LUT,
WATER_EDGE_TILE_LUT,
DRLGROOMTILE_GetTileCache,
applyGroundTransitions,
} from "../src/game/wilderness.ts"
import type {
WeightedGroundTile,
WildernessPiece,
TileCacheQuery,
TileCacheResult,
GroundTransitionOptions,
GroundTransitionResult,
} from "../src/game/wilderness.ts"
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
import type { Ds1, Ds1Cell, Ds1Floor } from "../src/formats/ds1.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(
width: number,
@ -446,9 +431,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
]
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Blend Test",
levelTypeName: "Act 1 - Wilderness",
levelId: 41,
levelName: "Rocky Waste Blend Test",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -484,9 +469,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Single Tile",
levelTypeName: "Act 1 - Wilderness",
levelId: 41,
levelName: "Rocky Waste Single Tile",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -508,9 +493,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
])
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor DT1 Test",
levelTypeName: "Act 1 - Wilderness",
levelId: 41,
levelName: "Rocky Waste DT1 Test",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -527,157 +512,6 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
expect(statsTiles[0]!.sequence).toBe(0)
expect(statsTiles[1]!.sequence).toBe(1)
})
test("generateWilderness populates groundTransitions stats with 0 missing tiles", () => {
const pieces = makeTestPieces()
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Transitions Test",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 888888,
pieces,
substitutions: [],
dt1Libraries: [dt1],
})
expect(result.stats.groundTransitions).toBeDefined()
expect(result.stats.groundTransitions?.transitionCells).toBeGreaterThanOrEqual(0)
expect(result.stats.groundTransitions?.roadTransitions).toBeGreaterThanOrEqual(0)
expect(result.stats.groundTransitions?.waterTransitions).toBeGreaterThanOrEqual(0)
// Verify every floor tile on the canvas is resolvable (0 missing tiles)
const available = new Set<string>()
for (const t of dt1.tiles) {
if (t.type === 0) available.add(`${String(t.style)}:${String(t.sequence)}`)
}
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const floor = result.level.cells[y]![x]!.floors[0]
if (floor && !floor.hidden && floor.prop1 !== 0) {
// The floor must either be in available tiles or from stamped border pieces
expect(typeof floor.style).toBe("number")
expect(typeof floor.sequence).toBe("number")
}
}
}
})
})
describe("Neighbor Mask Calculation (Issue #70)", () => {
test("calculates bitmask 0 for isolated cell with no matching neighbors", () => {
const canvas = createCanvas(5, 5, 1, 1, 0)
const mask = computeNeighborMask(canvas, 2, 2, () => false)
expect(mask).toBe(0)
})
test("calculates individual cardinal neighbor bitmasks (N, S, E, W)", () => {
const canvas = createCanvas(5, 5, 1, 1, 0)
// Test North: (2, 1) -> dx=0, dy=-1
const maskN = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 1)
expect(maskN).toBe(NEIGHBOR_MASK_N)
expect(maskN).toBe(0x10)
// Test South: (2, 3) -> dx=0, dy=+1
const maskS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 3)
expect(maskS).toBe(NEIGHBOR_MASK_S)
expect(maskS).toBe(0x08)
// Test East: (3, 2) -> dx=+1, dy=0
const maskE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 2)
expect(maskE).toBe(NEIGHBOR_MASK_E)
expect(maskE).toBe(0x40)
// Test West: (1, 2) -> dx=-1, dy=0
const maskW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 2)
expect(maskW).toBe(NEIGHBOR_MASK_W)
expect(maskW).toBe(0x02)
})
test("calculates individual diagonal corner bitmasks (NE, NW, SE, SW)", () => {
const canvas = createCanvas(5, 5, 1, 1, 0)
// Test NE: (3, 1) -> dx=+1, dy=-1
const maskNE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 1)
expect(maskNE).toBe(NEIGHBOR_MASK_NE)
expect(maskNE).toBe(0x80)
// Test NW: (1, 1) -> dx=-1, dy=-1
const maskNW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 1)
expect(maskNW).toBe(NEIGHBOR_MASK_NW)
expect(maskNW).toBe(0x04)
// Test SE: (3, 3) -> dx=+1, dy=+1
const maskSE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 3)
expect(maskSE).toBe(NEIGHBOR_MASK_SE)
expect(maskSE).toBe(0x20)
// Test SW: (1, 3) -> dx=-1, dy=+1
const maskSW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 3)
expect(maskSW).toBe(NEIGHBOR_MASK_SW)
expect(maskSW).toBe(0x01)
})
test("calculates composite neighbor bitmasks correctly", () => {
const canvas = createCanvas(5, 5, 1, 1, 0)
// N + S = 0x10 | 0x08 = 0x18
const maskNS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 2 && ny === 1) || (nx === 2 && ny === 3))
expect(maskNS).toBe(0x18)
// E + W = 0x40 | 0x02 = 0x42
const maskEW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 3 && ny === 2) || (nx === 1 && ny === 2))
expect(maskEW).toBe(0x42)
// NE + N + E = 0x80 | 0x10 | 0x40 = 0xD0
const maskCorner = computeNeighborMask(canvas, 2, 2, (_, nx, ny) =>
(nx === 3 && ny === 1) || (nx === 2 && ny === 1) || (nx === 3 && ny === 2),
)
expect(maskCorner).toBe(0xD0)
// All 8 neighbors = 0xFF (255)
const maskAll = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx !== 2 || ny !== 2)
expect(maskAll).toBe(0xFF)
})
test("safely handles canvas boundaries without error", () => {
const canvas = createCanvas(3, 3, 1, 1, 0)
// Top-left corner (0, 0): only (1, 0), (0, 1), (1, 1) are valid neighbors
const maskTL = computeNeighborMask(canvas, 0, 0, () => true)
// dx=+1, dy=0 (E: 0x40)
// dx=0, dy=+1 (S: 0x08)
// dx=+1, dy=+1 (SE: 0x20)
// Expected = 0x40 | 0x08 | 0x20 = 0x68
expect(maskTL).toBe(0x68)
// Bottom-right corner (2, 2): only (1, 2), (2, 1), (1, 1) are valid neighbors
const maskBR = computeNeighborMask(canvas, 2, 2, () => true)
// dx=-1, dy=0 (W: 0x02)
// dx=0, dy=-1 (N: 0x10)
// dx=-1, dy=-1 (NW: 0x04)
// Expected = 0x02 | 0x10 | 0x04 = 0x16
expect(maskBR).toBe(0x16)
})
test("NEIGHBOR_OFFSETS has exactly 8 directions with correct masks", () => {
expect(NEIGHBOR_OFFSETS).toHaveLength(8)
let combined = 0
for (const offset of NEIGHBOR_OFFSETS) {
combined |= offset.mask
}
expect(combined).toBe(0xFF)
})
})
describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => {
@ -783,251 +617,9 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
})
})
describe("applyGroundTransitions Auto-Tiling (Issue #70)", () => {
function setFloor(
canvas: ReturnType<typeof createCanvas>,
x: number,
y: number,
floor: { style: number; sequence: number; prop1?: number },
): void {
const cell = canvas.cells[y]?.[x]
if (!cell) return
;(cell.floors as Ds1Floor[])[0] = {
prop1: floor.prop1 ?? 2,
style: floor.style,
sequence: floor.sequence,
unknown1: 0,
unknown2: 0,
hidden: false,
}
}
test("generates road-to-ground edge transitions using DIRT_PATH_TILE_LUT while keeping interior road intact", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
// Initialize canvas with base ground: style 0, sequence 0
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
// Stamp a 3x3 road patch at (3, 3) to (5, 5) with style 0, sequence 1
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
const roadDt1 = makeMockDt1(
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
)
const res = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 1 - Wilderness',
levelId: 2,
})
expect(res.roadTransitions).toBeGreaterThan(0)
expect(res.waterTransitions).toBe(0)
expect(res.transitionCells).toBe(res.roadTransitions)
// Interior center cell at (4, 4) has all 8 road neighbors (mask = 0xFF)
// Interior road sequence in DIRT_PATH_TILE_LUT is sequence 1, so it remains intact!
const centerFloor = canvas.cells[4]![4]!.floors[0]!
expect(centerFloor.style).toBe(0)
expect(centerFloor.sequence).toBe(1)
// The 8 boundary road cells around (4, 4) must receive transition sequences from DIRT_PATH_TILE_LUT
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
if (rx === 4 && ry === 4) continue
const floor = canvas.cells[ry]![rx]!.floors[0]!
expect(floor.style).toBe(0)
expect(floor.sequence).toBeGreaterThanOrEqual(1)
expect(floor.sequence).toBeLessThanOrEqual(46)
}
}
// Ground cells outside road (e.g. (0, 0)) remain sequence 0
expect(canvas.cells[0]![0]!.floors[0]!.sequence).toBe(0)
})
test("generates water-to-ground edge transitions using WATER_EDGE_TILE_LUT while keeping interior deep water (24)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
// Initialize canvas with base ground: style 0, sequence 0
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
// Stamp a 3x3 water patch at (3, 3) to (5, 5) with style 2, sequence 0
for (let wy = 3; wy <= 5; wy += 1) {
for (let wx = 3; wx <= 5; wx += 1) {
setFloor(canvas, wx, wy, { style: 2, sequence: 0 })
}
}
const waterDt1 = makeMockDt1(
Array.from({ length: 25 }, (_, i) => ({ type: 0, style: 2, sequence: i })),
)
const res = applyGroundTransitions(canvas, {
libraries: [waterDt1],
dt1Mask: 1,
levelTypeName: 'Act 1 - Wilderness',
levelId: 2,
})
expect(res.waterTransitions).toBeGreaterThan(0)
expect(res.transitionCells).toBe(res.waterTransitions)
// Center water cell (4, 4) has all 8 water neighbors (mask = 0xFF) -> deep water sequence 24
const centerWater = canvas.cells[4]![4]!.floors[0]!
expect(centerWater.style).toBe(2)
expect(centerWater.sequence).toBe(24)
// Perimeter water cells receive water edge sequences (0..23)
for (let wy = 3; wy <= 5; wy += 1) {
for (let wx = 3; wx <= 5; wx += 1) {
if (wx === 4 && wy === 4) continue
const floor = canvas.cells[wy]![wx]!.floors[0]!
expect(floor.style).toBe(2)
expect(floor.sequence).toBeLessThan(24)
}
}
})
test("returns 0 transitions when dt1Libraries are undefined or empty (Issue #113)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
// No options -> 0 transitions
const resNoOptions = applyGroundTransitions(canvas)
expect(resNoOptions.transitionCells).toBe(0)
expect(resNoOptions.roadTransitions).toBe(0)
expect(resNoOptions.waterTransitions).toBe(0)
// Empty libraries array -> 0 transitions
const resEmptyLibs = applyGroundTransitions(canvas, { libraries: [] })
expect(resEmptyLibs.transitionCells).toBe(0)
expect(resEmptyLibs.roadTransitions).toBe(0)
})
test("does not inject transition tiles into non-Act-1 environments (Issue #113)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
const roadDt1 = makeMockDt1(
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
)
// Mesa 1 (levelId 104, Act 4 - Mesa)
const resMesa = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 4 - Mesa',
levelId: 104,
})
expect(resMesa.transitionCells).toBe(0)
// Desert 3 (levelId 43, Act 2 - Desert)
const resDesert = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 2 - Desert',
levelId: 43,
})
expect(resDesert.transitionCells).toBe(0)
// Barricade 2 (levelId 112, Act 5 - Barricade)
const resBarricade2 = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 5 - Barricade',
levelId: 112,
})
expect(resBarricade2.transitionCells).toBe(0)
// Barricade Snow (levelId 117, Act 5 - Barricade)
const resBarricadeSnow = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 5 - Barricade',
levelId: 117,
})
expect(resBarricadeSnow.transitionCells).toBe(0)
})
test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => {
expect(WATER_EDGE_TILE_LUT).toHaveLength(256)
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)
for (let i = 0; i < 255; i += 1) {
expect(WATER_EDGE_TILE_LUT[i]).toBeLessThanOrEqual(23)
}
})
test("guarantees 0 missing tiles by strictly respecting supplied DT1 libraries", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
// Stamp road
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
// Mock DT1 that only has style 0, sequence 0 and 1 (no transition sequences 2..46)
const restrictedDt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const res = applyGroundTransitions(canvas, {
libraries: [restrictedDt1],
dt1Mask: 1,
})
// Since transitions 2..46 are not available in DT1, no invalid sequences are assigned
expect(res.roadTransitions).toBe(0)
// Every cell on canvas is guaranteed to be in the restricted DT1
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style === 0 && (floor.sequence === 0 || floor.sequence === 1)).toBe(true)
}
}
})
})
describe('Canonical Ground Pools (Phase D3)', () => {
test('contains authentic pools for all wilderness level types', () => {
const expectedKeys = [
'Act 1 - Wilderness',
'Act 2 - Desert',
'Act 3 - Jungle',
'Act 3 - Kurast',
@ -1047,6 +639,8 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
expect(tile.sequence).toBeGreaterThanOrEqual(0)
}
}
// Act I outdoor ground comes from the DRLG port, not from a canonical pool.
expect(CANONICAL_GROUND_POOLS['Act 1 - Wilderness']).toBeUndefined()
})
test('Act 3 Kurast canonical ground pool uses authentic style 1 from DarkGrass.dt1', () => {

View File

@ -1,9 +1,5 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
type WildernessPiece,
} from "../src/game/wilderness.ts"
import { UNIMPLEMENTED_PASSES } from "../src/game/wilderness.ts"
import {
rollEliteModifiers,
applyEliteModifiers,
@ -13,211 +9,16 @@ import {
import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts"
import { parseTable } from "../src/game/acts.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
]
}
// Act I outdoor population (the Act I waypoints, shrines, Flavie and the SuperUniques of Cold Plains,
// Stony Field, Dark Wood, Burial Grounds and Moo Moo Farm) comes from the DRLG port and is checked on
// its output in tests/drlg-act1-population.test.ts.
describe("Wilderness Population Phase (Issue #36)", () => {
test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => {
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines")
})
test("generateWilderness spawns outdoor waypoints on waypoint levels (Cold Plains, Stony Field, Dark Wood, Black Marsh)", () => {
const pieces = makeTestPieces()
for (const levelId of [3, 4, 5, 6]) {
const result = generateWilderness({
levelId,
levelName: `Act 1 Outdoor ${levelId}`,
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x123456 + levelId,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
expect(result.stats.waypointTile).not.toBeNull()
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 37)
expect(wpObj).toBeDefined()
}
})
test("generateWilderness does not spawn outdoor waypoint on Blood Moor (levelId 2)", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x222222,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(0)
expect(result.stats.waypointTile).toBeNull()
})
test("generateWilderness scatters roadside shrines and interactive objects along dirt paths", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x998877,
pieces,
substitutions: [],
})
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThan(0)
expect(Number(result.stats.interactivesSpawned)).toBeGreaterThan(0)
expect(result.level.objects.length).toBeGreaterThan(2)
})
test("generateWilderness binds Guard Flavie (rogue2) on Blood Moor (2) and Cold Plains (3)", () => {
const pieces = makeTestPieces()
const bloodMoor = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x1111,
pieces,
substitutions: [],
})
const coldPlains = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x2222,
pieces,
substitutions: [],
})
const bmLandmarks = bloodMoor.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
expect(bmLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
expect(cpLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
})
test("generateWilderness binds canonical SuperUniques to their special presets (Bishibosh, Rakanishu, Treehead WoodFist)", () => {
const pieces = makeTestPieces()
const coldPlains = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x3333,
pieces,
substitutions: [],
})
const stonyField = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x4444,
pieces,
substitutions: [],
})
const darkWood = generateWilderness({
levelId: 5,
levelName: "Dark Wood",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x5555,
pieces,
substitutions: [],
})
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; role: string }>
const sfLandmarks = stonyField.stats.landmarkEntities as Array<{ id: string; role: string }>
const dwLandmarks = darkWood.stats.landmarkEntities as Array<{ id: string; role: string }>
expect(cpLandmarks.some(e => e.id === "Bishibosh" && e.role === "superunique")).toBe(true)
expect(sfLandmarks.some(e => e.id === "Rakanishu" && e.role === "superunique")).toBe(true)
expect(dwLandmarks.some(e => e.id === "Treehead WoodFist" && e.role === "superunique")).toBe(true)
})
test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => {
const rng = new Rng(0xabcdef)
const mods = rollEliteModifiers("unique", rng)

View File

@ -1,675 +0,0 @@
/**
* Wilderness Topological Reachability & Collision Boundary Stress Test Suite (Issue #455).
*
* Empirical verification harness for Challenger 2:
* 1. Flood-fill reachability from player spawn / Rogue Encampment gate (`TownSTrans`)
* to Den of Evil entrance (`DenEnt.ds1`), Cold Plains boundary (river bridge / exit),
* and all landmark presets (Pond1, Cott1, Circle, Clover).
* 2. Impassable choke point, void trap, and bisecting barrier detection.
* 3. Secondary border (`Bord5..8` peninsulas) and 2x3 interior closure (`Bord9..12`) safety.
* 4. Multi-seed stress fuzzing across horizontal (96x56) and vertical (56x96) layouts.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries, type D2Table, type ActTables } from '../src/game/acts.ts'
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
type WildernessResult,
} from '../src/game/wilderness.ts'
import { solveAct1WildernessTopology } from '../src/game/wilderness/wilderness-topology.ts'
const hasD2 = existsSync('samples/d2/d2data.mpq')
export interface LandmarkTarget {
name: string
tileX: number
tileY: number
widthTiles: number
heightTiles: number
reachable: boolean
minSubTileDist: number
}
export interface ReachabilityReport {
seed: number
orientation: 'horizontal' | 'vertical'
widthTiles: number
heightTiles: number
totalSubTiles: number
walkableSubTiles: number
largestComponentSize: number
connectivityRatio: number
townSpawn: { x: number; y: number }
denReachable: boolean
denDistance: number
exitReachable: boolean
exitDistance: number
landmarks: LandmarkTarget[]
minShortestPathClearance: number
hasBisectingBarrier: boolean
hasVoidTrapAtSpawn: boolean
}
describe.skipIf(!hasD2)('Challenger 2: Wilderness Topological Reachability & Collision Stress Suite', () => {
let archives: MountedArchives
let tables: ActTables
let lvlsubTable: D2Table
let act1Pieces: WildernessPiece[]
let act1Subs: WildernessSubstitution[]
const dt1Cache = new Map<string, Dt1>()
let dt1s: Dt1[] = []
beforeAll(async () => {
archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
tables = await loadActTables(archives)
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const val = cell(table, row, `File${String(slot)}`)
if (val === '' || val === '0') continue
levels.push(await loadDs1(val))
}
return levels
}
const WILDERNESS_FAMILIES = [
'Act 1 - Wild',
'Act 1 - Town 1 Transition',
'Act 1 - DOE Entrance',
'Act 1 - Cottages',
'Act 1 - Pond',
'Act 1 - Stone Fill',
'Act 1 - River',
'Act 1 - Bridge',
'Act 1 - Cave Entrance',
]
act1Pieces = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!WILDERNESS_FAMILIES.some(fam => name.startsWith(fam))) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
}
const tPiece = act1Pieces.find(p => /Town.*STrans|Transition.*S/i.test(p.name))?.levels[0]
console.log('TownSTrans ds1 size:', tPiece?.width, 'x', tPiece?.height)
act1Subs = []
for (const row of lvlsubTable.rows) {
const subType = Number(cell(lvlsubTable, row, 'Type'))
if (subType !== 0 && subType !== 1 && subType !== 6) continue
const file = cell(lvlsubTable, row, 'File')
if (!file || file === '0') continue
const levels = [await loadDs1(file)]
act1Subs.push({
name: cell(lvlsubTable, row, 'Name'),
type: subType,
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsubTable, row, 'BordType')),
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
levels,
})
}
const libraries = resolveLevelLibraries(tables, 2)
for (const n of libraries.dt1Names) {
if (!dt1Cache.has(n)) {
try {
dt1Cache.set(n, decodeDt1(await archives.read(n)))
} catch {
// Ignore missing optional DT1s
}
}
}
dt1s = libraries.dt1Names.map(n => dt1Cache.get(n)).filter((d): d is Dt1 => d !== undefined)
}, 30000)
/**
* Evaluates flood-fill reachability on sub-tile collision grid for a generated level.
*/
function evaluateReachability(seed: number): ReachabilityReport {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
})
if (seed === 359) {
console.log('Seed 359 stats.notes:', result.stats.notes)
}
const topo = solveAct1WildernessTopology(seed, 2)
const orientation = topo.width > topo.height ? 'horizontal' : 'vertical'
const scene = buildIsoMapScene(result.level, dt1s, levelSeed(`stress_${seed}`))
const cellsX = result.level.width
const cellsY = result.level.height
const gridW = scene.gridWidth ?? cellsX * SUB_TILES_PER_TILE
const gridH = scene.gridHeight ?? cellsY * SUB_TILES_PER_TILE
const totalSubTiles = gridW * gridH
// Check walkable sub-tiles
let walkableCount = 0
for (let i = 0; i < totalSubTiles; i += 1) {
if (scene.blocked[i] === 0) walkableCount += 1
}
// Locate town gate / spawn
const gateVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.find(v => v.type === 'gate')
const denVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.find(v => v.type === 'preset')
const exitVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.filter(v => v.type === 'gate')[1]
// Convert tile coordinates to sub-tile coordinates (center of tile = +2)
const spawnTileX = gateVertex?.x ?? Math.floor(cellsX / 2)
const spawnTileY = gateVertex?.y ?? 4
let startSx = spawnTileX * SUB_TILES_PER_TILE + 2
let startSy = spawnTileY * SUB_TILES_PER_TILE + 2
// Search local window for nearest walkable sub-tile if exact center is blocked
if (scene.blocked[startSy * gridW + startSx] !== 0) {
let found = false
for (let r = 1; r <= 20 && !found; r += 1) {
for (let dy = -r; dy <= r && !found; dy += 1) {
for (let dx = -r; dx <= r && !found; dx += 1) {
const sx = startSx + dx
const sy = startSy + dy
if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) {
if (scene.blocked[sy * gridW + sx] === 0) {
startSx = sx
startSy = sy
found = true
}
}
}
}
}
}
const spawnWalkable = scene.blocked[startSy * gridW + startSx] === 0
// BFS Flood-fill from start sub-tile
const dist = new Int32Array(totalSubTiles).fill(-1)
const prev = new Int32Array(totalSubTiles).fill(-1)
const queue = new Int32Array(totalSubTiles)
let qHead = 0
let qTail = 0
const startIdx = startSy * gridW + startSx
dist[startIdx] = 0
queue[qTail++] = startIdx
const DIRS = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
]
while (qHead < qTail) {
const curr = queue[qHead++]
const cx = curr % gridW
const cy = Math.floor(curr / gridW)
const d = dist[curr]!
for (const [dx, dy] of DIRS) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 0 && nx < gridW && ny >= 0 && ny < gridH) {
const nidx = ny * gridW + nx
if (dist[nidx] === -1 && scene.blocked[nidx] === 0) {
dist[nidx] = d + 1
prev[nidx] = curr
queue[qTail++] = nidx
}
}
}
}
const largestComponentSize = qTail
const connectivityRatio = walkableCount > 0 ? largestComponentSize / walkableCount : 0
if (seed === 359) {
let minTx = 999, maxTx = 0, minTy = 999, maxTy = 0
for (let i = 0; i < qTail; i += 1) {
const idx = queue[i]!
const tx = Math.floor((idx % gridW) / SUB_TILES_PER_TILE)
const ty = Math.floor(Math.floor(idx / gridW) / SUB_TILES_PER_TILE)
if (tx < minTx) minTx = tx
if (tx > maxTx) maxTx = tx
if (ty < minTy) minTy = ty
if (ty > maxTy) maxTy = ty
}
console.log(`[Seed 359 Spawn Component Bounds] tx: ${minTx}..${maxTx}, ty: ${minTy}..${maxTy}, spawnTile: (${Math.floor(startSx/5)}, ${Math.floor(startSy/5)})`)
console.log('Cell at 8, 44:', JSON.stringify(result.level.cells[8]?.[44]))
console.log('Cell at 9, 44:', JSON.stringify(result.level.cells[9]?.[44]))
console.log('Cell at 10, 44:', JSON.stringify(result.level.cells[10]?.[44]))
for (let ty = 6; ty <= 18; ty += 1) {
let rowStr = `ty=${ty.toString().padStart(2)}: `
for (let tx = 42; tx <= 48; tx += 1) {
let cellBlocked = 0
for (let sy = 0; sy < 5; sy += 1) {
for (let sx = 0; sx < 5; sx += 1) {
if (scene.blocked[(ty * 5 + sy) * gridW + (tx * 5 + sx)] !== 0) cellBlocked += 1
}
}
rowStr += ` tx${tx}:${cellBlocked}/25`
}
console.log(rowStr)
}
}
// Find all disconnected components
const visitedAll = new Uint8Array(totalSubTiles)
for (let i = 0; i < totalSubTiles; i += 1) {
if (dist[i] !== -1) visitedAll[i] = 1
}
const otherComponents: Array<{ size: number; sampleTile: { x: number; y: number } }> = []
for (let i = 0; i < totalSubTiles; i += 1) {
if (scene.blocked[i] === 0 && visitedAll[i] === 0) {
// BFS this component
let cSize = 0
const cQueue = [i]
visitedAll[i] = 1
const sampleTile = { x: (i % gridW) / SUB_TILES_PER_TILE, y: Math.floor(i / gridW) / SUB_TILES_PER_TILE }
while (cQueue.length > 0) {
const curr = cQueue.pop()!
cSize += 1
const cx = curr % gridW
const cy = Math.floor(curr / gridW)
for (const [dx, dy] of DIRS) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 0 && nx < gridW && ny >= 0 && ny < gridH) {
const nidx = ny * gridW + nx
if (visitedAll[nidx] === 0 && scene.blocked[nidx] === 0) {
visitedAll[nidx] = 1
cQueue.push(nidx)
}
}
}
}
otherComponents.push({ size: cSize, sampleTile })
}
}
otherComponents.sort((a, b) => b.size - a.size)
if (seed === 12345 || seed === 42 || seed === 359) {
console.log(`[Diagnostic Seed ${seed}] Total walkable: ${walkableCount}, Main component: ${largestComponentSize} (${(connectivityRatio * 100).toFixed(1)}%), Other components: ${otherComponents.length}`)
console.log(`Top 5 disconnected components:`, otherComponents.slice(0, 5))
}
// Parse landmark coordinates from stats.notes
const landmarks: LandmarkTarget[] = []
const notes = (result.stats.notes as string[]) ?? []
for (const note of notes) {
const match = note.match(/Placed ([\w\.-]+).*at \((\d+),\s*(\d+)\)/)
if (match) {
const name = match[1]!
const tileX = parseInt(match[2]!, 10)
const tileY = parseInt(match[3]!, 10)
let widthTiles = 16
let heightTiles = 16
if (/Circle|Clover/i.test(name)) {
widthTiles = 8
heightTiles = 8
} else if (/Town/i.test(name)) {
widthTiles = 56
heightTiles = 8
}
// Test reachability of landmark perimeter or entrance
let minDist = Infinity
for (let dy = -2; dy <= heightTiles + 2; dy += 1) {
for (let dx = -2; dx <= widthTiles + 2; dx += 1) {
const cx = tileX + dx
const cy = tileY + dy
if (cx < 0 || cx >= cellsX || cy < 0 || cy >= cellsY) continue
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
const gX = cx * SUB_TILES_PER_TILE + sx
const gY = cy * SUB_TILES_PER_TILE + sy
const idx = gY * gridW + gX
const d = dist[idx]
if (d !== undefined && d >= 0 && d < minDist) {
minDist = d
}
}
}
}
}
landmarks.push({
name,
tileX,
tileY,
widthTiles,
heightTiles,
reachable: minDist !== Infinity,
minSubTileDist: minDist === Infinity ? -1 : minDist,
})
}
}
// Den of Evil reachability
const denTarget = landmarks.find(l => /Den|DOE/i.test(l.name))
const denTile = denTarget ? { x: denTarget.tileX + 8, y: denTarget.tileY + 8 } : (denVertex ?? { x: 16, y: Math.floor(cellsY / 2) })
let minDenDist = Infinity
for (let dy = -3; dy <= 3; dy += 1) {
for (let dx = -3; dx <= 3; dx += 1) {
const cx = denTile.x + dx
const cy = denTile.y + dy
if (cx < 0 || cx >= cellsX || cy < 0 || cy >= cellsY) continue
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
const gX = cx * SUB_TILES_PER_TILE + sx
const gY = cy * SUB_TILES_PER_TILE + sy
const idx = gY * gridW + gX
const d = dist[idx]
if (d !== undefined && d >= 0 && d < minDenDist) {
minDenDist = d
}
}
}
}
}
if (seed === 359) {
console.log('Seed 359 denTarget:', denTarget, 'denTile:', denTile, 'minDenDist:', minDenDist, 'landmarks:', landmarks.map(l => ({ name: l.name, x: l.tileX, y: l.tileY, reachable: l.reachable })))
}
// Cold Plains exit reachability (Bridge or East exit)
const exitCol = topo.riverColumn ?? cellsX - 2
let minExitDist = Infinity
let exitSubTileTarget = -1
for (let r = 0; r < cellsY; r += 1) {
for (let c = exitCol * 8; c < cellsX; c += 1) {
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
const gX = c * SUB_TILES_PER_TILE + sx
const gY = r * SUB_TILES_PER_TILE + sy
const idx = gY * gridW + gX
const d = dist[idx]
if (d !== undefined && d >= 0 && d < minExitDist) {
minExitDist = d
exitSubTileTarget = idx
}
}
}
}
}
// Measure bottleneck clearance along shortest path to Exit
let minClearance = Infinity
if (exitSubTileTarget !== -1) {
let pCurr = exitSubTileTarget
let steps = 0
while (pCurr !== startIdx && pCurr !== -1 && steps < 5000) {
steps += 1
const px = pCurr % gridW
const py = Math.floor(pCurr / gridW)
// Measure clearance in radius
let localWalkable = 0
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
const sx = px + dx
const sy = py + dy
if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) {
if (scene.blocked[sy * gridW + sx] === 0) localWalkable += 1
}
}
}
if (localWalkable < minClearance) minClearance = localWalkable
pCurr = prev[pCurr]!
}
}
return {
seed,
orientation,
widthTiles: cellsX,
heightTiles: cellsY,
totalSubTiles,
walkableSubTiles: walkableCount,
largestComponentSize,
connectivityRatio,
townSpawn: { x: startSx, y: startSy },
denReachable: minDenDist !== Infinity,
denDistance: minDenDist,
exitReachable: minExitDist !== Infinity,
exitDistance: minExitDist,
landmarks,
minShortestPathClearance: minClearance === Infinity ? 0 : minClearance,
hasBisectingBarrier: minExitDist === Infinity || minDenDist === Infinity,
hasVoidTrapAtSpawn: !spawnWalkable || largestComponentSize < 200,
}
}
/* ======================================================================= *
* SUITE 1: Canonical Seeds & Dual-Orientation Verification
* ======================================================================= */
describe('Suite 1: Canonical Seeds & Dual-Orientation Verification', () => {
it('asserts 100% reachability on horizontal 96x56 Moor (Seed 0x12345678)', () => {
const rep = evaluateReachability(0x12345678)
expect(rep.orientation).toBe('horizontal')
expect(rep.widthTiles).toBe(96)
expect(rep.heightTiles).toBe(56)
expect(rep.hasVoidTrapAtSpawn).toBe(false)
expect(rep.hasBisectingBarrier).toBe(false)
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
expect(rep.connectivityRatio).toBeGreaterThan(0.88)
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
expect(rep.minShortestPathClearance).toBeGreaterThanOrEqual(3)
})
it('asserts 100% reachability on horizontal 96x56 Moor (Seed 12345)', () => {
const rep = evaluateReachability(12345)
expect(rep.orientation).toBe('horizontal')
expect(rep.widthTiles).toBe(96)
expect(rep.heightTiles).toBe(56)
expect(rep.hasVoidTrapAtSpawn).toBe(false)
expect(rep.hasBisectingBarrier).toBe(false)
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
expect(rep.connectivityRatio).toBeGreaterThan(0.88)
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
})
it('asserts 100% reachability on vertical 56x96 Moor (Seed 1)', () => {
const rep = evaluateReachability(1)
expect(rep.orientation).toBe('vertical')
expect(rep.widthTiles).toBe(56)
expect(rep.heightTiles).toBe(96)
expect(rep.hasVoidTrapAtSpawn).toBe(false)
expect(rep.hasBisectingBarrier).toBe(false)
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
expect(rep.connectivityRatio).toBeGreaterThan(0.78)
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
})
it('asserts 100% reachability on vertical 56x96 Moor (Seed 42)', () => {
const rep = evaluateReachability(42)
expect(rep.orientation).toBe('vertical')
expect(rep.widthTiles).toBe(56)
expect(rep.heightTiles).toBe(96)
expect(rep.hasVoidTrapAtSpawn).toBe(false)
expect(rep.hasBisectingBarrier).toBe(false)
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
expect(rep.connectivityRatio).toBeGreaterThan(0.78)
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
})
})
/* ======================================================================= *
* SUITE 2: Edge-Case Seeds (0, 0xFFFFFFFF, 0x7FFFFFFF, 666, 999999)
* ======================================================================= */
describe('Suite 2: Boundary & Extreme Seed Topologies', () => {
const boundarySeeds = [0, 666, 999999, 0x7fffffff, 0x5eed2001, 0xdeadbeef, 359]
for (const s of boundarySeeds) {
it(`verifies zero impassable bottlenecks & reachability on Seed ${s}`, () => {
const rep = evaluateReachability(s)
expect(rep.hasVoidTrapAtSpawn).toBe(false)
expect(rep.hasBisectingBarrier).toBe(false)
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
const minRatio = rep.orientation === 'horizontal' ? 0.85 : 0.75
expect(rep.connectivityRatio).toBeGreaterThan(minRatio)
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
})
}
})
/* ======================================================================= *
* SUITE 3: 50-Seed Fuzzing Sweep (Topological Invariants)
* ======================================================================= */
describe('Suite 3: 50-Seed Random Fuzzing Sweep', () => {
it('verifies 100% topological reachability across 50 pseudo-random seeds', () => {
const testSeeds = Array.from({ length: 50 }, (_, i) => 100 + i * 37)
let horizontalCount = 0
let verticalCount = 0
let passedCount = 0
for (const s of testSeeds) {
const rep = evaluateReachability(s)
if (rep.orientation === 'horizontal') horizontalCount += 1
else verticalCount += 1
if (rep.hasBisectingBarrier) {
console.log(`[Suite 3 Failure] Seed ${s}: orientation=${rep.orientation}, denReachable=${rep.denReachable}, exitReachable=${rep.exitReachable}, denDist=${rep.denDistance}, exitDist=${rep.exitDistance}`)
}
expect(rep.hasVoidTrapAtSpawn).toBe(false)
expect(rep.hasBisectingBarrier, `Seed ${s} has bisecting barrier (denReachable=${rep.denReachable}, exitReachable=${rep.exitReachable})`).toBe(false)
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
const minRatio = rep.orientation === 'horizontal' ? 0.85 : 0.75
expect(rep.connectivityRatio).toBeGreaterThan(minRatio)
const unreachableLandmarks = rep.landmarks.filter(l => !l.reachable)
if (unreachableLandmarks.length > 0) {
console.log(`[Suite 3 Landmark Failure] Seed ${s}:`, unreachableLandmarks.map(l => ({ name: l.name, x: l.tileX, y: l.tileY, w: l.widthTiles, h: l.heightTiles })))
}
expect(unreachableLandmarks, `Seed ${s} has unreachable landmarks`).toEqual([])
passedCount += 1
}
expect(passedCount).toBe(50)
expect(horizontalCount).toBeGreaterThan(15)
expect(verticalCount).toBeGreaterThan(15)
})
})
/* ======================================================================= *
* SUITE 4: Secondary Borders & 2x3 Closures Boundary Non-Trapping
* ======================================================================= */
describe('Suite 4: Secondary Borders & 2x3 Interior Closures Non-Trapping', () => {
it('asserts 2x3 closures (Bord9..12) do not trap player spawn or block critical paths', () => {
// In Bord9..12, interior 2x3 closures sit in block (2,2)..(3,3).
// We assert that spawn is outside of (2,2)..(3,3), and distance from Town to Exit is finite.
const rep = evaluateReachability(0x12345678)
const spawnTileX = Math.floor(rep.townSpawn.x / SUB_TILES_PER_TILE)
const spawnTileY = Math.floor(rep.townSpawn.y / SUB_TILES_PER_TILE)
// Spawn must not be inside the 2x3 closure blocks [16..31, 16..31]
const inside2x3Closure = spawnTileX >= 16 && spawnTileX < 32 && spawnTileY >= 16 && spawnTileY < 32
expect(inside2x3Closure).toBe(false)
// Shortest path to exit exists and has minimum corridor clearance
expect(rep.exitReachable).toBe(true)
expect(rep.exitDistance).toBeGreaterThan(0)
expect(rep.minShortestPathClearance).toBeGreaterThan(0)
})
it('asserts secondary peninsulas (Bord5..8) do not sever perimeter or town gate', () => {
const rep = evaluateReachability(12345)
// Town transition is at top or left edge; peninsulas must not seal it off
expect(rep.denReachable).toBe(true)
expect(rep.exitReachable).toBe(true)
expect(rep.hasBisectingBarrier).toBe(false)
})
})
/* ======================================================================= *
* SUITE 5: River Bridge Crossing & Dirt Road Walkability
* ======================================================================= */
describe('Suite 5: River Bridge Crossing & Dirt Road Walkability', () => {
it('verifies river bridge creates unbroken walkable corridor connecting mainland to exit', () => {
const result: WildernessResult = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x12345678,
pieces: act1Pieces,
substitutions: act1Subs,
})
const scene = buildIsoMapScene(result.level, dt1s, levelSeed('bridge_test'))
const gridW = scene.gridWidth ?? result.level.width * SUB_TILES_PER_TILE
// River columns are x in [80..95] in tiles -> sub-tiles [400..479]
// Bridge is placed at bridgeRow. Find bridgeRow from stats.notes
const riverColStart = (result.level.width - 2) * 8
let bridgeCrossingWalkable = false
// Check all rows across river columns: at least one row must have a continuous walkable sub-tile path across the river
for (let y = 0; y < result.level.height; y += 1) {
let spanWalkable = true
for (let x = riverColStart; x < result.level.width; x += 1) {
const sy = y * SUB_TILES_PER_TILE + 2
const sx = x * SUB_TILES_PER_TILE + 2
if (scene.blocked[sy * gridW + sx] !== 0) {
spanWalkable = false
break
}
}
if (spanWalkable) {
bridgeCrossingWalkable = true
break
}
}
expect(bridgeCrossingWalkable).toBe(true)
})
})
})

View File

@ -1,8 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
createCanvas,
stampAct1DirtRoads,
stampAct1InteriorStoneWalls,
stampAct2DesertRoads,
stampAct2SandstoneBerms,
stampAct3KurastAvenues,
@ -215,14 +213,11 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
expect(countWallTiles(canvas, 2, 129)).toBe(0)
})
it('dispatches Act 1 Dirt Roads based on levelId 3 without artificial cross dividers', () => {
it('refuses Act I outdoor levels (levelId 3): they come from the DRLG port (src/game/drlg)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 0, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 2, 129)).toBe(0)
expect(() => stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/)
expect(() => stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/)
})
it('dispatches based on levelTypeName when levelId is generic', () => {
@ -253,17 +248,11 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
expect(countWallTiles(c2, 1, 129)).toBe(0)
})
it('strictly skips Cow Level (levelId 39)', () => {
it('refuses the Cow Level (39): an Act I outdoor level generated by the DRLG port', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
expect(countFloorTiles(canvas, 0, 194)).toBe(0)
expect(countFloorTiles(canvas, 1, 194)).toBe(0)
expect(countFloorTiles(canvas, 5, 194)).toBe(0)
expect(countWallTiles(canvas, 1, 129)).toBe(0)
expect(countWallTiles(canvas, 2, 129)).toBe(0)
expect(() => stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/)
expect(() => stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/)
})
})
@ -375,38 +364,24 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
expect(frozenDivides).toBeGreaterThan(15)
})
it('generates Cow Level (Level 39) without roads or stone divider networks', () => {
const cowBorder: WildernessPiece = {
it.each([2, 3, 4, 5, 6, 7, 17, 39])('refuses Act I outdoor level %i: it comes from the DRLG port (src/game/drlg)', (levelId) => {
const act1Border: WildernessPiece = {
name: 'Act 1 - Wild Border 1',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 39,
levelName: 'Moo Moo Farm',
expect(() => generateWilderness({
levelId,
levelName: `Act I level ${String(levelId)}`,
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [cowBorder],
pieces: [act1Border],
substitutions: [],
})
let roadFloors = 0
let dividerWalls = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 0 || f.style === 1 || f.style === 5) && f.prop1 === 194)) roadFloors += 1
if (cell.walls.some(w => (w.style === 1 || w.style === 2) && w.prop1 === 129)) dividerWalls += 1
}
}
expect(roadFloors).toBe(0)
expect(dividerWalls).toBe(0)
})).toThrow(/DRLG port/)
})
it('stampThemedRoads preserves preset walls on the highway spine', () => {
@ -416,7 +391,7 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
const hubY = outY * TILES_PER_BLOCK + 4
// Preset directly intersects the road spine
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 1 - Ruin', x: hubX - 2, y: hubY - 2, w: 5, h: 5 },
{ name: 'Protected Preset', x: hubX - 2, y: hubY - 2, w: 5, h: 5 },
]
// Paint preset wall inside the preset on the highway spine
const targetCell = canvas.cells[hubY]?.[hubX]
@ -431,7 +406,8 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
hidden: false,
})
stampAct1DirtRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
// Act 2 caravan routes go through the shared stampThemedRoads rasterizer.
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
// The wall inside the preset must NOT have been wiped
expect(targetCell!.walls.some(w => w.style === 99 && w.prop1 === 100)).toBe(true)

View File

@ -1,239 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts'
import type { ActTables, D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts'
import {
generateWilderness,
DIRT_PATH_TILE_LUT,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1-4)', () => {
let archives: MountedArchives
let tables: ActTables
let lvlsubTable: D2Table
let act1Pieces: WildernessPiece[]
let act1Subs: WildernessSubstitution[]
const dt1Cache = new Map<string, Dt1>()
beforeAll(async () => {
archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
tables = await loadActTables(archives)
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const families = [
'Act 1 - Wild',
'Act 1 - Town 1 Transition',
'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Graveyard',
'Act 1 - Bivouac',
'Act 1 - Fallen Camp',
'Act 1 - Cottages',
'Act 1 - Pond',
'Act 1 - Camp',
'Act 1 - Ruin',
'Act 1 - Stone Fill',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - Swamp Fill',
'Act 1 - Tree Fill',
]
act1Pieces = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(f => name.startsWith(f))) continue
const levels = []
for (let i = 1; i <= 6; i += 1) {
const f = cell(tables.lvlprest, row, `File${String(i)}`)
if (f && f !== '0') {
levels.push(decodeDs1(await archives.read(tileMemberPath(f))))
}
}
if (levels.length > 0) {
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
}
}
act1Subs = []
for (const row of lvlsubTable.rows) {
if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue
const file = cell(lvlsubTable, row, 'File')
if (!file || file === '0') continue
const ds1 = decodeDs1(await archives.read(tileMemberPath(file)))
const vals = (prefix: string) =>
[0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0)
act1Subs.push({
name: cell(lvlsubTable, row, 'Name'),
type: 0,
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsubTable, row, 'BordType')),
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
prob: vals('Prob'),
trials: vals('Trials'),
max: vals('Max'),
levels: [ds1],
})
}
// Pre-cache DT1 libraries for Act 1 wilderness levels
for (const id of [2, 3, 4, 5, 6, 7, 17]) {
const libraries = resolveLevelLibraries(tables, id)
for (const n of libraries.dt1Names) {
if (!dt1Cache.has(n)) {
dt1Cache.set(n, decodeDt1(await archives.read(n)))
}
}
}
}, 30000)
it('DIRT_PATH_TILE_LUT matches exact D2MOO 8-neighbor bitmask tile sequences', () => {
expect(DIRT_PATH_TILE_LUT.length).toBe(256)
// Straight horizontal road top edge (West + East + South neighbors = 0x6B -> 0x11 = 17)
expect(DIRT_PATH_TILE_LUT[0x6b]).toBe(0x11)
// Straight horizontal road bottom edge (West + East + North neighbors = 0xD6 -> 0x16 = 22)
expect(DIRT_PATH_TILE_LUT[0xd6]).toBe(0x16)
// Straight vertical road left edge (North + South + East neighbors = 0xF8 -> 0x18 = 24)
expect(DIRT_PATH_TILE_LUT[0xf8]).toBe(0x18)
// Straight vertical road right edge (North + South + West neighbors = 0x1F -> 0x14 = 20)
expect(DIRT_PATH_TILE_LUT[0x1f]).toBe(0x14)
})
it('places directional borders (Border 1..8) around the level perimeter', () => {
const res = generateWilderness({
levelId: 2,
levelName: 'Act 1 - Wilderness 1',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x123456,
pieces: act1Pieces,
substitutions: [],
})
const usage = res.stats.borderUsage as Record<string, number>
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR).
//
// Counted by slot number rather than by exact name. Act 1's north, west and
// three of its corners prefer rock cliff pieces over wooden fence ones
// (`Act 1 - Wild Cliff Border 6A`), and the cliff family also carries
// lettered variants — all of which are still "the NW corner piece". What
// must hold is that each corner block is stamped exactly once; stamping
// twice is what leaves a corner with two overlapping silhouettes.
const slotUsage = (slot: number): number => {
const pattern = new RegExp(`\\bBorder\\s*0?${String(slot)}[A-Z]?$`, 'i')
let total = 0
for (const [name, count] of Object.entries(usage)) {
if (pattern.test(name)) total += count
}
return total
}
expect(slotUsage(5)).toBe(1)
expect(slotUsage(6)).toBe(1)
expect(slotUsage(7)).toBe(1)
expect(slotUsage(8)).toBe(1)
// Edges: Border 1 (Bottom), Border 2 (Left), Border 3 (Top), Border 4 (Right)
expect(slotUsage(1)).toBeGreaterThan(0)
expect(slotUsage(2)).toBeGreaterThan(0)
expect(slotUsage(3)).toBeGreaterThan(0)
expect(slotUsage(4)).toBeGreaterThan(0)
})
it.each([
{ id: 2, name: 'Act 1 - Wilderness 1', expectedPreset: 'Act 1 - DOE Entrance' },
{ id: 3, name: 'Act 1 - Wilderness 2', expectedPreset: 'Act 1 - Cave Entrance' },
{ id: 4, name: 'Act 1 - Wilderness 3', expectedPreset: 'Act 1 - Cairn Stones' },
{ id: 5, name: 'Act 1 - Wilderness 4', expectedPreset: 'Act 1 - Inifus' },
{ id: 6, name: 'Act 1 - Wilderness 5', expectedPreset: 'Act 1 - Tower 1' },
{ id: 7, name: 'Act 1 - Wilderness 6', expectedPreset: 'Act 1 - Cave Entrance' },
{ id: 17, name: 'Act 1 - Graveyard', expectedPreset: 'Act 1 - Graveyard' },
])('generates connected dirt roads and special presets for $name (id=$id)', async ({ id, name, expectedPreset }) => {
const levelRow = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === id)!
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subTheme = Math.max(0, Number(cell(tables.levels, levelRow, 'SubTheme')))
const subs = subType === 0 ? act1Subs : []
const res = generateWilderness({
levelId: id,
levelName: name,
levelTypeName: 'Act 1 - Wilderness',
sizeX: Number(cell(tables.levels, levelRow, 'SizeX')),
sizeY: Number(cell(tables.levels, levelRow, 'SizeY')),
subType,
subTheme,
seed: 0x5eed_1000 + id,
pieces: act1Pieces,
substitutions: subs,
})
// Verify road stats
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
expect(Number(res.stats.roadSegments)).toBeGreaterThan(0)
expect(Number(res.stats.anchors)).toBeGreaterThanOrEqual(2)
// Verify special preset placement
const presets = res.stats.specialPresets as string[]
expect(presets).toContain(expectedPreset)
// Count actual dirt road tiles (style 0, sequence 1..46) on canvas floor[0]
let dirtTileCount = 0
for (let y = 0; y < res.level.height; y += 1) {
for (let x = 0; x < res.level.width; x += 1) {
const floor = res.level.cells[y]?.[x]?.floors[0]
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
dirtTileCount += 1
}
}
}
expect(dirtTileCount).toBeGreaterThan(30)
// Verify DT1 tile resolvability (0 missing tiles)
const libraries = resolveLevelLibraries(tables, id)
const dt1s: Dt1[] = libraries.dt1Names.map(n => dt1Cache.get(n)!)
const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test'))
expect(scene.missingTiles).toBe(0)
}, 15000)
it('safely handles out-of-bounds road neighbors without corrupting border bitmasks', () => {
const res = generateWilderness({
levelId: 2,
levelName: 'Act 1 - Wilderness 1',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x5eed_2001,
pieces: act1Pieces,
substitutions: [],
})
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
let dirtRoadTiles = 0
for (let y = 0; y < res.level.height; y += 1) {
for (let x = 0; x < res.level.width; x += 1) {
const floor = res.level.cells[y]?.[x]?.floors[0]
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
dirtRoadTiles += 1
}
}
}
expect(dirtRoadTiles).toBeGreaterThanOrEqual(Number(res.stats.roadCells))
})
})

View File

@ -115,121 +115,17 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
})
})
describe("Sequential generation with seam interlocking (Blood Moor -> Cold Plains -> Stony Field)", () => {
test("interlocks Blood Moor East exit with Cold Plains West entrance", () => {
const pieces = makeTestPieces()
// Step 1: Generate Blood Moor (level 2)
const bloodMoorResult = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x123456,
pieces,
substitutions: [],
})
const bloodMoorOpenings = bloodMoorResult.stats.gateOpenings
expect(bloodMoorOpenings).toBeDefined()
expect(bloodMoorOpenings!.length).toBeGreaterThan(0)
// Find East exit to Cold Plains (level 3)
const eastGate = bloodMoorOpenings!.find(g => g.side === "east" && g.toLevelId === 3)
expect(eastGate).toBeDefined()
const sharedYBlock = eastGate!.blockCoord
expect(sharedYBlock).toBeGreaterThanOrEqual(1)
expect(sharedYBlock).toBeLessThanOrEqual(8)
// Step 2: Generate Cold Plains (level 3) with seam constraint matching Blood Moor's East exit
const coldPlainsResult = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x654321,
pieces,
substitutions: [],
seamConstraints: [
{
side: "west",
fromLevelId: 2,
toLevelId: 3,
blockCoord: sharedYBlock,
...(eastGate!.tileCoord ? { tileCoord: { x: 0, y: eastGate!.tileCoord.y } } : {}),
},
],
})
const coldPlainsOpenings = coldPlainsResult.stats.gateOpenings
expect(coldPlainsOpenings).toBeDefined()
// Find West entrance from Blood Moor (level 2)
const westGate = coldPlainsOpenings!.find(g => g.side === "west" && g.toLevelId === 2)
expect(westGate).toBeDefined()
expect(westGate!.blockCoord).toBe(sharedYBlock)
// If tileCoord was provided, verify along-seam coordinate matches
if (eastGate!.tileCoord && westGate!.tileCoord) {
expect(westGate!.tileCoord.y).toBe(eastGate!.tileCoord.y)
}
// Step 3: Cold Plains has exit to Stony Field (level 4) on North
const northGate = coldPlainsOpenings!.find(g => g.side === "north" && g.toLevelId === 4)
expect(northGate).toBeDefined()
const sharedXBlock = northGate!.blockCoord
expect(sharedXBlock).toBeGreaterThanOrEqual(1)
expect(sharedXBlock).toBeLessThanOrEqual(8)
// Step 4: Generate Stony Field (level 4) with seam constraint on South matching Cold Plains North exit
const stonyFieldResult = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0xabcdef,
pieces,
substitutions: [],
seamConstraints: [
{
side: "south",
fromLevelId: 3,
toLevelId: 4,
blockCoord: sharedXBlock,
...(northGate!.tileCoord ? { tileCoord: { x: northGate!.tileCoord.x, y: 79 } } : {}),
},
],
})
const stonyFieldOpenings = stonyFieldResult.stats.gateOpenings
expect(stonyFieldOpenings).toBeDefined()
const southGate = stonyFieldOpenings!.find(g => g.side === "south" && g.toLevelId === 3)
expect(southGate).toBeDefined()
expect(southGate!.blockCoord).toBe(sharedXBlock)
if (northGate!.tileCoord && southGate!.tileCoord) {
expect(southGate!.tileCoord.x).toBe(northGate!.tileCoord.x)
}
})
})
// Act I outdoor levels come from the DRLG port (src/game/drlg); their seams are checked on the
// port's act layout in tests/drlg-act1-population.test.ts. The planned-gate / seam-constraint
// machinery below is shared by Acts 2-5 and runs on the Act II desert levels (41 Rocky Waste,
// 42 Dry Hills, 43 Far Oasis, 56 Halls of the Dead).
describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => {
test("honors gates with explicit blockCoord", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -238,9 +134,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
pieces,
substitutions: [],
gates: [
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: 3 },
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 6 },
{ label: "Underground", side: "east", toLevelId: 10, blockCoord: 7 },
{ label: "Rocky Waste", side: "west", toLevelId: 41, blockCoord: 3 },
{ label: "Far Oasis", side: "north", toLevelId: 43, blockCoord: 6 },
{ label: "Halls of the Dead", side: "east", toLevelId: 56, blockCoord: 7 },
],
})
@ -261,9 +157,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
const pieces = makeTestPieces()
const targetTileY = 8 * 4 + 3 // block 4, tile 35
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -272,7 +168,7 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
pieces,
substitutions: [],
gates: [
{ label: "Blood Moor", side: "west", toLevelId: 2, tileCoord: { x: 3, y: targetTileY } },
{ label: "Rocky Waste", side: "west", toLevelId: 41, tileCoord: { x: 3, y: targetTileY } },
],
})
@ -290,9 +186,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
const targetSeamY = 5
const levelA = generateWilderness({
levelId: 2,
levelId: 41,
levelName: "Level A",
levelTypeName: "Act 1 - Wilderness",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -300,13 +196,13 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
seed: 0x111,
pieces,
substitutions: [],
gates: [{ label: "Level B", side: "east", toLevelId: 3, blockCoord: targetSeamY }],
gates: [{ label: "Level B", side: "east", toLevelId: 42, blockCoord: targetSeamY }],
})
const levelB = generateWilderness({
levelId: 3,
levelId: 42,
levelName: "Level B",
levelTypeName: "Act 1 - Wilderness",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -314,7 +210,7 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
seed: 0x222,
pieces,
substitutions: [],
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: targetSeamY }],
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: targetSeamY }],
})
const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")!
@ -331,9 +227,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
const targetSeamX = 6
const levelB = generateWilderness({
levelId: 3,
levelId: 42,
levelName: "Level B",
levelTypeName: "Act 1 - Wilderness",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -341,13 +237,13 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
seed: 0x333,
pieces,
substitutions: [],
gates: [{ label: "Level C", side: "north", toLevelId: 4, blockCoord: targetSeamX }],
gates: [{ label: "Level C", side: "north", toLevelId: 43, blockCoord: targetSeamX }],
})
const levelC = generateWilderness({
levelId: 4,
levelId: 43,
levelName: "Level C",
levelTypeName: "Act 1 - Wilderness",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -355,7 +251,10 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
seed: 0x444,
pieces,
substitutions: [],
seamConstraints: [{ side: "south", fromLevelId: 3, toLevelId: 4, blockCoord: targetSeamX }],
// Only the west/east gates are defaults; the south seam gate is planned and takes its block
// from the seam constraint.
gates: [{ label: "Level B", side: "south", toLevelId: 42 }],
seamConstraints: [{ side: "south", fromLevelId: 42, toLevelId: 43, blockCoord: targetSeamX }],
})
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")!
@ -372,9 +271,9 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
test("clamps negative blockCoord to 1 without crashing", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -382,19 +281,19 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
seed: 0x555,
pieces,
substitutions: [],
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: -10 }],
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: -10 }],
})
const west = result.stats.gateOpenings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(1)
})
test("clamps excessive blockCoord to gridWidth - 2 without crashing", () => {
test("clamps excessive blockCoord to gridHeight - 2 without crashing", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -402,11 +301,11 @@ describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", ()
seed: 0x666,
pieces,
substitutions: [],
seamConstraints: [{ side: "north", fromLevelId: 4, toLevelId: 3, blockCoord: 999 }],
seamConstraints: [{ side: "east", fromLevelId: 43, toLevelId: 42, blockCoord: 999 }],
})
const north = result.stats.gateOpenings!.find(g => g.side === "north")
expect(north?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
const east = result.stats.gateOpenings!.find(g => g.side === "east")
expect(east?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
})
})
})

View File

@ -1,12 +1,13 @@
/**
* Wilderness Seed Sensitivity & Act 1 Regression Guard Test Suite (Milestone M1).
* Wilderness Seed Sensitivity Test Suite (Milestone M1).
*
* Verifies:
* 1. Act 1 outdoor levels remain 100% bit-identical to the pre-recorded baseline table
* across seeds 0x301cd095, 0x416d61c5, and 0xdeadbeef.
* 2. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5.
* 3. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway).
* 4. Deterministic re-run invariance holds unconditionally.
* 1. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5.
* 2. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway).
* 3. Deterministic re-run invariance holds unconditionally.
*
* Act I outdoor levels come from the DRLG port (src/game/drlg) and are covered by
* tests/drlg-act1-oracle.test.ts (bit-exact parity with D2) and tests/drlg-act1-population.test.ts.
*/
import { describe, expect, it, beforeAll } from 'vitest'
@ -125,21 +126,6 @@ function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
/**
* Baseline hashes for Act 1 outdoor levels under seeds [0x301cd095, 0x416d61c5, 0xdeadbeef].
* These hashes must remain 100% bit-identical to prevent regressions in Act 1.
*/
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
3: ['72028c01', 'acbb96f3', '322c0f9f'], // Cold Plains
4: ['378d6251', '23f8b483', '57598911'], // Stony Field
5: ['7f762a59', '29f0ecbe', '4fe78d47'], // Dark Wood
6: ['3ff738b1', '713c852f', 'eddff41d'], // Black Marsh
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
}
/* ------------------------------------------------------------------------- *
* Test Suite 1: Hermetic API & Strict Contract Safety
* ------------------------------------------------------------------------- */
@ -233,7 +219,9 @@ describe('Hermetic Contract & RNG Parameter Safety', () => {
const hasMpqArchives = existsSync('samples/d2/d2data.mpq')
describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 1..5', () => {
// Act I outdoor levels come from the DRLG port (src/game/drlg): its determinism and seed sensitivity
// are checked in tests/drlg-act1-population.test.ts, its parity with D2 in tests/drlg-act1-oracle.test.ts.
describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 2..5', () => {
let archives: MountedArchives
let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table }
let lvlsub: D2Table
@ -260,15 +248,6 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -287,7 +266,7 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff/i.test(name)
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
@ -366,22 +345,6 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
return result.level
}
it('Act 1 baseline hashes remain 100% bit-identical to baseline table', async () => {
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
for (const levelId of act1Levels) {
const expected = ACT1_BASELINE_HASHES[levelId]!
const actual: string[] = []
for (const seed of TEST_SEEDS) {
const level = await generateLevelForSeed(levelId, seed)
actual.push(canonicalHash(level))
}
expect(actual).toEqual(expected)
}
}, 60000)
it('Act 2 procedural outdoor levels produce distinct hashes across seeds', async () => {
const act2Levels = [41, 42, 43, 44, 45, 46]
@ -435,8 +398,8 @@ describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts
}, 60000)
it('Wilderness generation determinism invariance holds across repeated runs', async () => {
// Spot check one level per Act
const sampleLevels = [2, 41, 79, 104, 111]
// Spot check one level per Act (Act I: tests/drlg-act1-population.test.ts)
const sampleLevels = [41, 79, 104, 111]
for (const levelId of sampleLevels) {
const seed = TEST_SEEDS[0]
const first = await generateLevelForSeed(levelId, seed)

View File

@ -1,11 +1,4 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
import type { ActTables } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import { describe, it, expect } from 'vitest'
import { Rng } from '../src/game/rng.ts'
import {
catmullRomPoint,
@ -14,22 +7,15 @@ import {
DRLGVER_CreateVertices,
buildOutdoorRoadPolylines,
buildRoadNetworkPolylines,
rasterizeDirtPathSegment,
generateDirtPaths,
createCanvas,
generateWilderness,
DIRT_PATH_TILE_LUT,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type {
Act1OutdoorPlan,
DrlgVertex,
WildernessPiece,
WildernessSubstitution,
} from '../src/game/wilderness.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
import type { Act1OutdoorPlan } from '../src/game/wilderness.ts'
// Act I outdoor levels (2-7, 17, 39) come from the 1:1 D2MOO DRLG port in
// src/game/drlg (verified by tests/drlg-act1-oracle.test.ts); the Act I dirt
// path rasterizer and its end-to-end cases were removed with the invented
// generator. The vertex-graph / spline helpers below are still used by the
// Acts 2-5 wilderness generator.
describe('Control Vertex Graph & Spline Roads (Issue #66)', () => {
describe('Catmull-Rom Spline Mathematics & Sampling', () => {
it('accurately interpolates start and end control points at t=0 and t=1', () => {
@ -264,228 +250,4 @@ describe('Control Vertex Graph & Spline Roads (Issue #66)', () => {
expect(obstacleBlocks[hubBy]![hubBx]).toBe(false)
})
})
describe('Dirt Path Rasterization & Obstacle Clearance', () => {
it('rasterizes 2-3 cell wide continuous dirt road onto canvas', () => {
const canvas = createCanvas(80, 80, 1, 1, 1)
const polylines = [
[
{ x: 10, y: 20 },
{ x: 20, y: 22 },
{ x: 30, y: 25 },
{ x: 40, y: 30 },
{ x: 50, y: 38 },
],
]
const roadRes = generateDirtPaths(canvas, polylines)
expect(roadRes.roadCells).toBeGreaterThan(20)
expect(roadRes.roadSegments).toBe(polylines[0]!.length - 1)
// Check canvas floors contain road styles (style 0, sequences 1..46)
let stampedRoadFloors = 0
for (let y = 0; y < 80; y += 1) {
for (let x = 0; x < 80; x += 1) {
const floor = canvas.cells[y]?.[x]?.floors[0]
if (floor && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
stampedRoadFloors += 1
}
}
}
expect(stampedRoadFloors).toBe(roadRes.roadCells)
})
it('strictly avoids painting dirt road cells inside roadObstacleBlocks', () => {
const canvas = createCanvas(80, 80, 1, 1, 1)
const roadObstacleBlocks = Array.from({ length: 10 }, () =>
Array.from({ length: 10 }, () => false),
)
// Block block (3, 3) -> cells [24..31, 24..31]
roadObstacleBlocks[3]![3] = true
// Polyline that passes near or through block 3
const polylines = [
[
{ x: 10, y: 28 },
{ x: 26, y: 28 },
{ x: 45, y: 28 },
],
]
generateDirtPaths(canvas, polylines, roadObstacleBlocks)
// Verify no cell in block (3, 3) was stamped with dirt road
for (let y = 24; y < 32; y += 1) {
for (let x = 24; x < 32; x += 1) {
const floor = canvas.cells[y]?.[x]?.floors[0]
expect(floor?.sequence).not.toBeGreaterThanOrEqual(1)
}
}
})
it('correctly maps 8-neighbor bitmask to DIRT_PATH_TILE_LUT sequence', () => {
const grid = new Uint8Array(20 * 20)
// Place a cross of dirt road cells:
// (10, 10), (10, 9), (10, 11), (9, 10), (11, 10)
grid[10 * 20 + 10] = 1
grid[9 * 20 + 10] = 1
grid[11 * 20 + 10] = 1
grid[10 * 20 + 9] = 1
grid[10 * 20 + 11] = 1
// Compute bitmask for center (10, 10):
// top (10, 9) = bit 4 (0x10)
// bottom (10, 11) = bit 3 (0x08)
// left (9, 10) = bit 1 (0x02)
// right (11, 10) = bit 6 (0x40)
const expectedMask = 0x10 | 0x08 | 0x02 | 0x40
const expectedSeq = DIRT_PATH_TILE_LUT[expectedMask]
expect(expectedSeq).toBeGreaterThan(0)
})
})
describe.skipIf(!hasD2)('End-to-End Spline Roads in Act 1 Wilderness Generation', () => {
let archives: MountedArchives
let tables: ActTables
let lvlsubTable: any
let act1Pieces: WildernessPiece[]
let act1Subs: WildernessSubstitution[]
beforeAll(async () => {
archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
tables = await loadActTables(archives)
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const families = [
'Act 1 - Wild',
'Act 1 - Town 1 Transition',
'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Graveyard',
'Act 1 - Bivouac',
'Act 1 - Fallen Camp',
'Act 1 - Cottages',
'Act 1 - Pond',
'Act 1 - Camp',
'Act 1 - Ruin',
'Act 1 - Stone Fill',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - Swamp Fill',
'Act 1 - Tree Fill',
]
act1Pieces = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(f => name.startsWith(f))) continue
const levels = []
for (let i = 1; i <= 6; i += 1) {
const f = cell(tables.lvlprest, row, `File${String(i)}`)
if (f && f !== '0') {
levels.push(decodeDs1(await archives.read(tileMemberPath(f))))
}
}
if (levels.length > 0) {
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
}
}
act1Subs = []
for (const row of lvlsubTable.rows) {
if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue
const file = cell(lvlsubTable, row, 'File')
if (!file || file === '0') continue
const ds1 = decodeDs1(await archives.read(tileMemberPath(file)))
const vals = (prefix: string) =>
[0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0)
act1Subs.push({
name: cell(lvlsubTable, row, 'Name'),
type: 0,
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsubTable, row, 'BordType')),
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
prob: vals('Prob'),
trials: vals('Trials'),
max: vals('Max'),
levels: [ds1],
})
}
}, 30000)
it('populates drlgVertices with gates, hub, and special preset anchors', () => {
const res = generateWilderness({
levelId: 2, // Blood Moor (Den of Evil)
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x5eed_0002,
pieces: act1Pieces,
substitutions: act1Subs,
})
expect(res.stats.drlgVertices).toBeDefined()
const vertices = res.stats.drlgVertices as DrlgVertex[]
expect(vertices.length).toBeGreaterThanOrEqual(3)
const vertexTypes = vertices.map(v => v.type)
expect(vertexTypes).toContain('hub')
expect(vertexTypes).toContain('gate')
// Blood Moor has Den of Evil preset
expect(vertexTypes).toContain('preset')
expect(res.stats.roadPolylines).toBeDefined()
const polylines = res.stats.roadPolylines as { x: number; y: number }[][]
expect(polylines.length).toBeGreaterThanOrEqual(2)
// Verify roadCells and dirt road floor stamping
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
})
it('generates smooth spline road in Stony Field connecting Cairn Stones to Gates', () => {
const res = generateWilderness({
levelId: 4, // Stony Field (Cairn Stones)
levelName: 'Stony Field',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x5eed_0004,
pieces: act1Pieces,
substitutions: act1Subs,
})
const vertices = res.stats.drlgVertices as DrlgVertex[]
expect(vertices).toBeDefined()
const cairnVertex = vertices.find(v => v.label?.includes('Cairn Stones') || v.type === 'preset')
expect(cairnVertex).toBeDefined()
const hubVertex = vertices.find(v => v.type === 'hub')
expect(hubVertex).toBeDefined()
// Spline polylines connect to hub
const polylines = res.stats.roadPolylines as { x: number; y: number }[][]
expect(polylines.length).toBeGreaterThanOrEqual(2)
// Verify all polyline points are within map bounds
for (const line of polylines) {
for (const pt of line) {
expect(pt.x).toBeGreaterThanOrEqual(0)
expect(pt.x).toBeLessThan(res.level.width)
expect(pt.y).toBeGreaterThanOrEqual(0)
expect(pt.y).toBeLessThan(res.level.height)
}
}
})
})
})

View File

@ -1,480 +0,0 @@
/**
* Wilderness Seed Fuzzing & Stress Testing (Issue #455).
*
* Empirical Challenger Verification Suite:
* - Sweeps >= 100 seeds across boundary values (0, 1, 0xFFFFFFFF, INT32_MAX, etc.),
* adversarial inputs, primes, and uniform pseudo-random distributions.
* - Asserts 5 critical invariants across ALL seeds:
* 1. Dimensions are strictly 96x56 or 56x96 for Blood Moor.
* 2. Zero preset overlaps across all placed landmarks.
* 3. River position invariant (outer columns x = nGridWidth - 2 when river flag active).
* 4. Performance budget (< 50ms per map generation).
* 5. Road-to-river bridge connectivity (no water moat severing the road from the bridge).
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
type WildernessResult,
} from '../src/game/wilderness.ts'
import { solveAct1WildernessTopology } from '../src/game/wilderness/wilderness-topology.ts'
interface PresetRect {
readonly name: string
readonly x: number
readonly y: number
readonly w: number
readonly h: number
}
function rectsOverlap(a: PresetRect, b: PresetRect): boolean {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
)
}
function extractPlacedPresets(notes: readonly string[]): PresetRect[] {
const presets: PresetRect[] = []
for (const note of notes) {
const m = /Placed ([^ ]+) .*? at \((\d+), (\d+)\)/.exec(note)
if (!m) continue
const name = m[1]!
const x = parseInt(m[2]!, 10)
const y = parseInt(m[3]!, 10)
let w = 8
let h = 8
if (/Town.*Trans/i.test(name)) {
w = 56
h = 8
} else if (/Den|DOE/i.test(name)) {
w = 16
h = 16
} else if (/Pond/i.test(name)) {
w = 16
h = 16
} else if (/Cott/i.test(name)) {
w = 16
h = 16
} else if (/Circle/i.test(name)) {
w = 8
h = 8
} else if (/Clover/i.test(name)) {
w = 8
h = 8
}
presets.push({ name, x, y, w, h })
}
return presets
}
function evaluateDirtRoadConnectivity(cells: Array<{ x: number; y: number }>): {
totalCells: number
componentCount: number
largestComponentSize: number
} {
if (cells.length === 0) {
return { totalCells: 0, componentCount: 0, largestComponentSize: 0 }
}
const cellSet = new Set(cells.map(c => `${c.x},${c.y}`))
const visited = new Set<string>()
const components: string[][] = []
for (const key of cellSet) {
if (visited.has(key)) continue
const queue = [key]
visited.add(key)
const comp = [key]
while (queue.length > 0) {
const current = queue.shift()!
const [cx, cy] = current.split(',').map(Number) as [number, number]
for (const [dx, dy] of [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
]) {
const neighbor = `${cx + dx},${cy + dy}`
if (cellSet.has(neighbor) && !visited.has(neighbor)) {
visited.add(neighbor)
queue.push(neighbor)
comp.push(neighbor)
}
}
}
components.push(comp)
}
// Filter out isolated floor patches from landmark presets & borders (< 60 tiles)
// so only full road and bridge structures are evaluated
const roadComponents = components.filter(c => c.length >= 60)
return {
totalCells: cells.length,
componentCount: roadComponents.length,
largestComponentSize: roadComponents[0]?.length ?? 0,
}
}
// Deterministic seed generator for 128 seeds
function generateFuzzSeeds(): number[] {
const boundarySeeds: number[] = [
0,
1,
2,
3,
42,
12345,
65535,
65536,
0x12345678,
0x55555555,
0x7FFFFFFF,
0x80000000,
0x80000001,
0xAAAAAAAA,
0xDEADBEEF,
0xCAFEBABE,
0xFFFFFFFE,
0xFFFFFFFF,
7919,
104729,
]
let state = 0x455 ^ 0x113c
const nextRand = (): number => {
let t = (state += 0x6D2B79F5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return (t ^ (t >>> 14)) >>> 0
}
const fuzzSeeds = [...boundarySeeds]
while (fuzzSeeds.length < 128) {
fuzzSeeds.push(nextRand())
}
return fuzzSeeds
}
const hasD2Archives = existsSync('samples/d2/d2data.mpq')
describe.skipIf(!hasD2Archives)('Challenger 1: Empirical Wilderness Seed Fuzzing & Stress Testing (Issue #455)', () => {
let act1Pieces: WildernessPiece[]
let act1Subs: WildernessSubstitution[]
const WILDERNESS_PIECE_FAMILIES = [
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
'Act 1 - Graveyard',
]
beforeAll(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 {
// Skip optional archive if missing
}
}
const actTables = await loadActTables(archives)
const lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const val = cell(table, row, `File${String(slot)}`)
if (val === '' || val === '0') continue
levels.push(await loadDs1(val))
}
return levels
}
// Load Act 1 wilderness preset pieces
act1Pieces = []
for (const row of actTables.lvlprest.rows) {
const name = cell(actTables.lvlprest, row, 'Name')
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
const levels = await rowDs1s(actTables.lvlprest, row)
if (levels.length === 0) continue
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
}
// Load Act 1 substitutions (SubTypes 0, 1, 6)
act1Subs = []
for (const row of lvlsubTable.rows) {
const subType = Number(cell(lvlsubTable, row, 'Type'))
if (subType !== 0 && subType !== 1 && subType !== 6) continue
const file = cell(lvlsubTable, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
act1Subs.push({
name: cell(lvlsubTable, row, 'Name'),
type: subType,
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsubTable, row, 'BordType')),
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
levels,
})
}
// Warm-up JIT
for (let i = 0; i < 3; i += 1) {
generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 12345 + i,
pieces: act1Pieces,
substitutions: act1Subs,
})
}
})
const seeds = generateFuzzSeeds()
it('Stress Test 1: Sizing invariant across 128 seeds (strictly 96x56 or 56x96)', () => {
let horizontalCount = 0
let verticalCount = 0
for (const seed of seeds) {
const result = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
})
const isHorizontal = result.level.width === 96 && result.level.height === 56
const isVertical = result.level.width === 56 && result.level.height === 96
expect(
isHorizontal || isVertical,
`Seed 0x${(seed >>> 0).toString(16)} generated invalid dimensions ${result.level.width}x${result.level.height}`,
).toBe(true)
if (isHorizontal) horizontalCount += 1
if (isVertical) verticalCount += 1
}
expect(horizontalCount + verticalCount).toBe(seeds.length)
expect(horizontalCount).toBeGreaterThan(0)
expect(verticalCount).toBeGreaterThan(0)
})
it('Stress Test 2: Landmark preset anti-overlap invariant across 128 seeds (0 collisions)', () => {
for (const seed of seeds) {
const result = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
})
const notes = result.stats.notes ?? []
const presets = extractPlacedPresets(notes)
expect(presets.some(p => /Town.*Trans/i.test(p.name)), `Seed 0x${seed.toString(16)} missing TownSTrans`).toBe(true)
expect(presets.some(p => /Den|DOE/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Den of Evil`).toBe(true)
expect(presets.some(p => /Pond/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Pond1`).toBe(true)
expect(presets.some(p => /Cott/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Cottage`).toBe(true)
expect(presets.some(p => /Circle/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Circle`).toBe(true)
expect(presets.some(p => /Clover/i.test(p.name)), `Seed 0x${seed.toString(16)} missing Clover`).toBe(true)
for (let a = 0; a < presets.length; a += 1) {
const pa = presets[a]!
expect(pa.x).toBeGreaterThanOrEqual(0)
expect(pa.y).toBeGreaterThanOrEqual(0)
expect(pa.x + pa.w).toBeLessThanOrEqual(result.level.width)
expect(pa.y + pa.h).toBeLessThanOrEqual(result.level.height)
for (let b = a + 1; b < presets.length; b += 1) {
const pb = presets[b]!
const overlap = rectsOverlap(pa, pb)
expect(
overlap,
`Seed 0x${seed.toString(16)} collision between ${pa.name} and ${pb.name}`,
).toBe(false)
}
}
}
})
it('Stress Test 3: River outer column position invariant across 128 seeds (x = nGridWidth - 2)', () => {
let riverLayouts = 0
for (const seed of seeds) {
const topo = solveAct1WildernessTopology(seed, 2)
if (!topo.hasRiver) continue
riverLayouts += 1
const result = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
})
const riverStartX = topo.riverColumn * 8
let waterInRiver = 0
let waterOutsideRiver = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cellRecord = result.level.cells[y]?.[x]
if (cellRecord?.floors.some(f => f.style === 2)) {
if (x >= riverStartX) {
waterInRiver += 1
} else {
waterOutsideRiver += 1
}
}
}
}
expect(waterInRiver, `Seed 0x${seed.toString(16)} missing river water tiles`).toBeGreaterThan(0)
expect(waterOutsideRiver, `Seed 0x${seed.toString(16)} leaked river water into mainland (x < ${riverStartX})`).toBe(0)
}
expect(riverLayouts).toBeGreaterThan(0)
})
it('Stress Test 4: Performance budget (< 50ms per map generation) across 128 seeds', () => {
const times: number[] = []
for (const seed of seeds) {
const t0 = performance.now()
generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
})
const elapsed = performance.now() - t0
times.push(elapsed)
expect(elapsed, `Seed 0x${seed.toString(16)} exceeded budget: ${elapsed.toFixed(2)}ms`).toBeLessThan(75)
}
const sorted = [...times].sort((a, b) => a - b)
const min = sorted[0]!
const max = sorted[sorted.length - 1]!
const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length
const p95 = sorted[Math.floor(sorted.length * 0.95)]!
expect(avg).toBeLessThan(35)
expect(max).toBeLessThan(75)
expect(p95).toBeLessThan(50)
})
it('Stress Test 5 (ADVERSARIAL CHALLENGE): Road network connectivity to bridge crossing without water moat severance', () => {
// Collect all seeds where river and bridge are generated
for (const seed of seeds) {
const topo = solveAct1WildernessTopology(seed, 2)
if (!topo.hasRiver) continue
const result = generateWilderness({
levelId: 2,
levelName: 'Blood Moor',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act1Pieces,
substitutions: act1Subs,
})
// Collect all road & bridge tiles across the entire canvas
const roadCells: Array<{ x: number; y: number }> = []
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cellRecord = result.level.cells[y]?.[x]
if (cellRecord?.floors.some(f => (f.style === 0 && f.sequence >= 1 && f.sequence <= 46 && f.prop1 === 194) || (f.style === 1 && f.prop1 === 194) || (f.style === 4 && f.prop1 === 194))) {
roadCells.push({ x, y })
}
}
}
const connectivity = evaluateDirtRoadConnectivity(roadCells)
// Bug detected: river bridge wooden planks are stamped with style 1, but separated
// from the mainland dirt road by a 2-tile gap of deep water (style 2), fragmenting
// the road into 2 disconnected components!
expect(
connectivity.componentCount,
`Seed 0x${(seed >>> 0).toString(16)} road network is fragmented into ${connectivity.componentCount} disconnected components due to water moat gap between road and bridge at columns (${topo.riverColumn * 8}..${topo.riverColumn * 8 + 4})`,
).toBe(1)
}
})
it('Stress Test 6 (ROOT CAUSE VERIFICATION): Authentic river and bridge DS1 piece resolution in wilderness generator', () => {
// Audit the piece finding logic from wilderness.ts:7472-7476:
// const riverPieces = {
// upper: request.pieces.find(p => /URiverC/i.test(p.name))?.levels[0],
// lower: request.pieces.find(p => /LRiverC/i.test(p.name))?.levels[0],
// bridge: request.pieces.find(p => /Bridge/i.test(p.name))?.levels[0],
// }
const riverUpperPiece = act1Pieces.find(p => /River.*Upper|URiverC/i.test(p.name))
const riverLowerPiece = act1Pieces.find(p => /River.*Lower|LRiverC/i.test(p.name))
// This assertion confirms resolution: p.name in LvlPrest is "Act 1 - River Upper" and "Act 1 - River Lower",
// and /River.*Upper|URiverC/i and /River.*Lower|LRiverC/i match them cleanly.
expect(
riverUpperPiece !== undefined && riverLowerPiece !== undefined,
`Piece lookup in wilderness.ts:7472: /River.*Upper|URiverC/i and /River.*Lower|LRiverC/i match "Act 1 - River Upper" and "Act 1 - River Lower"`,
).toBe(true)
})
})

View File

@ -54,16 +54,13 @@ function makeMockDs1(width: number, height: number): Ds1 {
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
test("contains all 25 canonical landmark entities across Acts 1-5", () => {
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(25)
test("contains all 19 canonical landmark entities across Acts 2-5", () => {
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(19)
// Act 1 (6 entities: 5 SuperUniques + 1 NPC)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Flavie" && e.levelIds.includes(3))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bishibosh" && e.levelIds.includes(3))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Rakanishu" && e.levelIds.includes(4))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Treehead WoodFist" && e.levelIds.includes(5))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Blood Raven" && e.levelIds.includes(17))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "The Countess" && e.levelIds.includes(25))).toBe(true)
// Act 1: none. Act I outdoor levels come from the DRLG port (src/game/drlg), whose preset units
// place Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven, the Cow King and Flavie; that is
// checked on the port's output in tests/drlg-act1-population.test.ts.
expect(CANONICAL_LANDMARK_ENTITIES.filter(e => e.act < 2 || e.levelIds.some(id => id < 40))).toEqual([])
// Act 2 (5 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
@ -105,38 +102,38 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
const superUniquesTsv = [
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
"Bishibosh\tfallen2\t8\t9\t0\t2\t5\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
"Rakanishu\tfallen3\t17\t6\t0\t3\t6\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
"Beetleburst\tscarab3\t8\t9\t0\t2\t5\tscarab1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
"Dark Elder\tzombie4\t17\t6\t0\t3\t6\tzombie1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
].join("\n")
const suTable = mockTable(superUniquesTsv)
const entities = buildLandmarkEntitiesFromTables(suTable)
const bishibosh = entities.find(e => e.id === "Bishibosh")
expect(bishibosh).toBeDefined()
expect(bishibosh?.monsterId).toBe("fallen2")
expect(bishibosh?.modifiers).toEqual(["magicresistant", "fireenchant"])
expect(bishibosh?.minionCount).toEqual([2, 5])
const beetleburst = entities.find(e => e.id === "Beetleburst")
expect(beetleburst).toBeDefined()
expect(beetleburst?.monsterId).toBe("scarab3")
expect(beetleburst?.modifiers).toEqual(["magicresistant", "fireenchant"])
expect(beetleburst?.minionCount).toEqual([2, 5])
const rakanishu = entities.find(e => e.id === "Rakanishu")
expect(rakanishu).toBeDefined()
expect(rakanishu?.monsterId).toBe("fallen3")
expect(rakanishu?.modifiers).toEqual(["lightenchant", "fast"])
expect(rakanishu?.minionCount).toEqual([3, 6])
const darkElder = entities.find(e => e.id === "Dark Elder")
expect(darkElder).toBeDefined()
expect(darkElder?.monsterId).toBe("zombie4")
expect(darkElder?.modifiers).toEqual(["lightenchant", "fast"])
expect(darkElder?.minionCount).toEqual([3, 6])
})
test("resolves objectId from MonPreset.txt", () => {
const monPresetTsv = [
"Act\tPlace\tComponent\tObjectId",
"1\tCairn Stones\t1\t105",
"2\tDesert Oasis 1\t1\t105",
"4\tChaos Sanctuary\t1\t350",
].join("\n")
const mpTable = mockTable(monPresetTsv)
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
const rakanishu = entities.find(e => e.name === "Rakanishu")
expect(rakanishu?.objectId).toBe(105)
const beetleburst = entities.find(e => e.name === "Beetleburst")
expect(beetleburst?.objectId).toBe(105)
const hadriel = entities.find(e => e.name === "Hadriel")
expect(hadriel?.objectId).toBe(350)
@ -144,18 +141,20 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
})
describe("spawnLandmarkEntities in Wilderness Generator", () => {
test("places Rakanishu centered on Cairn Stones preset piece", () => {
const cairnDs1 = makeMockDs1(16, 16)
// Act I SuperUniques (Rakanishu on the Cairn Stones, ...) are preset units of the DRLG port's
// maps; tests/drlg-act1-population.test.ts checks them. The same anchoring for Acts 2-5:
test("places Creeping Feature centered on the Desert Tomb 1 preset piece", () => {
const tombDs1 = makeMockDs1(16, 16)
const borderDs1 = makeMockDs1(8, 8)
const pieces: WildernessPiece[] = [
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] },
{ name: "Act 1 - Cairn Stones", border: false, levels: [cairnDs1] },
{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] },
{ name: "Act 2 - Desert Tomb 1", border: false, levels: [tombDs1] },
]
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
levelId: 41,
levelName: "Rocky Waste",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -163,24 +162,24 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
seed: 42,
pieces,
substitutions: [],
specialPresets: ["Act 1 - Cairn Stones"],
specialPresets: ["Act 2 - Desert Tomb 1"],
})
expect(result.stats.landmarkEntities).toBeDefined()
const rakanishu = result.stats.landmarkEntities?.find(e => e.name === "Rakanishu")
expect(rakanishu).toBeDefined()
expect(rakanishu?.levelId).toBe(4)
const creeping = result.stats.landmarkEntities?.find(e => e.name === "Creeping Feature")
expect(creeping).toBeDefined()
expect(creeping?.levelId).toBe(41)
// Verify subtile coordinates: tileX * 5 + 2
expect(rakanishu?.subtileX).toBe(rakanishu!.tileX * 5 + 2)
expect(rakanishu?.subtileY).toBe(rakanishu!.tileY * 5 + 2)
expect(creeping?.subtileX).toBe(creeping!.tileX * 5 + 2)
expect(creeping?.subtileY).toBe(creeping!.tileY * 5 + 2)
// Verify canvas.objects contains the Type 1 entity
const obj = result.level.objects.find(
o => o.type === 1 && o.x === rakanishu!.subtileX && o.y === rakanishu!.subtileY
o => o.type === 1 && o.x === creeping!.subtileX && o.y === creeping!.subtileY
)
expect(obj).toBeDefined()
expect(obj?.id).toBe(rakanishu?.objectId)
expect(obj?.id).toBe(creeping?.objectId)
})
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
@ -260,8 +259,8 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
id: "CustomBoss",
name: "Test Custom Boss",
role: "superunique",
act: 1,
levelIds: [4],
act: 2,
levelIds: [42],
monsterId: "fallen1",
defaultLandmark: "Custom Landmark",
modifiers: ["lightenchant", "fast"],
@ -273,15 +272,15 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
]
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 123,
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
substitutions: [],
customLandmarks,
})
@ -297,24 +296,24 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
const borderDs1 = makeMockDs1(8, 8)
const result = generateWilderness({
levelId: 5, // Dark Wood (Treehead WoodFist)
levelName: "Dark Wood",
levelTypeName: "Act 1 - Wilderness",
levelId: 43, // Far Oasis (Beetleburst)
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 100,
sizeY: 100,
subType: 6,
subTheme: 0,
seed: 99,
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
substitutions: [],
})
const treehead = result.stats.landmarkEntities?.find(e => e.name === "Treehead WoodFist")
expect(treehead).toBeDefined()
expect(treehead?.tileX).toBeGreaterThanOrEqual(1)
expect(treehead?.tileX).toBeLessThan(99)
expect(treehead?.tileY).toBeGreaterThanOrEqual(1)
expect(treehead?.tileY).toBeLessThan(99)
const beetleburst = result.stats.landmarkEntities?.find(e => e.name === "Beetleburst")
expect(beetleburst).toBeDefined()
expect(beetleburst?.tileX).toBeGreaterThanOrEqual(1)
expect(beetleburst?.tileX).toBeLessThan(99)
expect(beetleburst?.tileY).toBeGreaterThanOrEqual(1)
expect(beetleburst?.tileY).toBeLessThan(99)
})
})

View File

@ -256,15 +256,15 @@ describe('BordType 0 really is one cluster for the whole level', () => {
test('the second BordType 0 row gets nothing left to spend', () => {
const result = generateWilderness({
levelId: 2,
levelName: 'Cold Plains',
levelTypeName: 'Act 1 - Wilderness',
levelId: 42,
levelName: 'Dry Hills',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 1'),
pieces: makeMockBorderPieces('Act 2'),
substitutions: makeBordType0Rows(),
})
@ -280,38 +280,9 @@ describe('BordType 0 really is one cluster for the whole level', () => {
})
})
// Act I outdoor levels (and their dirt paths: DRLGOUTDOORS_SpawnAct1DirtPaths) come from the DRLG
// port (src/game/drlg), so the telemetry below covers the procedural passes of Acts 2-5.
describe('Procedural terrain pass telemetry tracks real cell counts (Issue #62 & #65)', () => {
test('Act 1 wilderness tracks procedural road cells with zero artificial dividers (Issue #65)', () => {
const result = generateWilderness({
levelId: 2,
levelName: 'Cold Plains',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 1'),
substitutions: [],
})
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralDividerCells).toBe(0)
expect(result.stats.proceduralWallCells).toBe(0)
// Verify 0 artificial divider wall cells on canvas (walls with style 2, prop1 129)
let wallCount = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.walls.some(w => !w.hidden && w.style === 2 && w.prop1 === 129)) {
wallCount += 1
}
}
}
expect(wallCount).toBe(0)
})
test('Act 2 desert tracks procedural roads, water (oasis), and walls (canyon + berms)', () => {
const rocky = generateWilderness({
levelId: 41,

View File

@ -351,11 +351,11 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
})
test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => {
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 96,
sizeY: 96,
subType: 6,
@ -365,7 +365,7 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
substitutions: [],
})
const shrineIds = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
const shrineIds = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id))
// If multiple shrines are spawned, check pairwise distance >= 7
@ -385,20 +385,20 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
})
test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => {
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
// Use multiple seeds to observe companion spawning (60% probability)
let foundClusteredCompanion = false
const act1ChestsAndScatter = new Set([
...ACT_POPULATION_CONFIG[1].chestPool,
...ACT_POPULATION_CONFIG[1].scatterPool,
const act2ChestsAndScatter = new Set([
...ACT_POPULATION_CONFIG[2].chestPool,
...ACT_POPULATION_CONFIG[2].scatterPool,
])
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
const act2Shrines = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
for (let s = 0; s < 10; s += 1) {
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
@ -408,8 +408,8 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
substitutions: [],
})
const shrines = result.level.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
const companions = result.level.objects.filter(o => o.type === 2 && act1ChestsAndScatter.has(o.id))
const shrines = result.level.objects.filter(o => o.type === 2 && act2Shrines.has(o.id))
const companions = result.level.objects.filter(o => o.type === 2 && act2ChestsAndScatter.has(o.id))
for (const shrine of shrines) {
const sx = Math.floor(shrine.x / 5)

View File

@ -15,7 +15,7 @@ runtime shims and the build script live in this repository.
npx tsx scripts/extract-d2moo-tables.ts samples/d2 tools/d2moo-oracle/data
# 2. 32-bit build (needs g++ with -m32 / gcc-multilib).
./tools/d2moo-oracle/build.sh
./tools/d2moo-oracle/build.sh # or: OUT=/some/dir ./tools/d2moo-oracle/build.sh
# 3. Generate.
./tools/d2moo-oracle/build/d2moo-oracle --data tools/d2moo-oracle/data \
@ -28,6 +28,9 @@ npx tsx scripts/extract-d2moo-tables.ts samples/d2 tools/d2moo-oracle/data
- `--levels` must be Act I outdoor levels (DrlgType 3).
- `--isolated` allocates a fresh act for every level. The output must equal the sequential run,
which proves that a level's generation does not depend on other levels.
- `build.sh` compiles in a temporary directory and installs the binary into `$OUT` (default
`tools/d2moo-oracle/build`) with `cp` to `d2moo-oracle.tmp` followed by `mv` over `d2moo-oracle`, so
a concurrently running oracle is never replaced by a half-written file.
- Both `build/` and `data/` are git-ignored.
The binary is 32-bit so that every D2MOO struct has the exact layout of the compiled 1.13c `.bin`
@ -41,11 +44,18 @@ The driver runs D2MOO's own code for:
- `DRLG_InitLevel`, then `DRLGOUTDOORS_GenerateLevel` and `DRLGOUTWILD_InitAct1OutdoorLevel`.
- Room creation.
- Room activation, mirroring `DRLGACTIVATE_InitializeRoomEx` (DrlgActivate.cpp:317-331) and
`DRLGACTIVATE_RoomEx_EnsureHasRoom` (95-105) up to `DRLGROOMTILE_InitRoomGrids`:
`DRLGACTIVATE_RoomEx_EnsureHasRoom` (95-115) up to `DRLGROOMTILE_AddRoomMapTiles`:
1. `DRLGROOMTILE_LoadDT1FilesForRoom`
2. `DRLGPRESET_SpawnHardcodedPresetUnits` (preset rooms only)
3. `sub_6FD77BB0`
4. `DRLGROOMTILE_InitRoomGrids`, which re-seeds the room from `dwInitSeed`.
5. `DRLGROOMTILE_AddRoomMapTiles`: the tile data of the room (the variant picks of
`DRLGROOMTILE_GetTileCache` roll the room seed), the room-seam merge with the near rooms
(`DRLGROOMTILE_GetCreateLinkedTileData` / `DRLGROOMTILE_LinkedTileDataManager`), the warps
(`DRLGROOMTILE_AddWarp`), then `DRLGOUTROOM_InitializeDrlgOutdoorRoom` or
`DRLGPRESET_AddPresetRoomMapTiles` (both build the `DRLGLOGIC_*` coord lists).
`DRLG_CreateRoomForRoomEx` (the D2ActiveRoomStrc, built by D2Game) is not run.
The outside world is provided by `src/stubs.cpp`. Its policy: anything the generator genuinely needs
is implemented with real data, and everything else **fails fast** (exit code 2).
@ -64,7 +74,12 @@ is implemented with real data, and everything else **fails fast** (exit code 2).
- For Act I every shadow lookup returns rarity 0, so there is no roll. This is visible in the
trace as `T 13 … 1 0`.
- The order of the returned tiles (slot order, then DT1 file order) is not verified against
D2CMP.dll. It only decides which tile variant is picked, and the oracle does not dump that.
D2CMP.dll. It decides which tile variant `DRLGROOMTILE_GetTileCache` picks, which `mapTiles`
dumps (`pTile`); the TypeScript shim (`src/game/drlg/drlg-d2cmp.ts`) uses the same order.
- `FOG_AllocPool` / `FOG_ReallocPool` are `calloc` / `realloc`. `FOG_ReallocPool` zero-fills the grown
part of the block (glibc `calloc` clears the whole usable size), so the entries that
`DRLGROOMTILE_ReallocRoofTileGrid` appends are deterministic. D2Common never writes `unk0x10`,
`nIntensity` and `unk0x2C` of those entries; in the game they keep whatever the pool block held.
**Modelling decisions (documented, not guessed):**
@ -72,15 +87,21 @@ is implemented with real data, and everything else **fails fast** (exit code 2).
with the seed of the first activated room. That seed only matters for the randomly skipped units
of `DRLGPRESET_AddPresetUnitToDrlgMap` (Tormentor, Taintbreeder, Riftwraith, floor traps,
object 581), and none of those occur in Act I outdoor DS1s.
- The activation order also decides the room-seam merge: the room activated later edits the tiles of
the near rooms activated before it (also across level seams) and rolls their seeds.
- Activating a room initialises the levels behind its warps (`sub_6FD77BB0` → `DRLG_InitLevel`).
The sequential mode therefore generates every requested level first and activates the rooms
afterwards, so each level's RNG trace stays under its own context.
- Tile selection (`DRLGROOMTILE_AddRoomMapTiles`) and unit spawning are outside the oracle.
- `pDrlgRoom->pRoom` stays NULL for every room, so `D2Common_COLLISION_FirstFn_6FD41000` is a no-op
(D2Collision.cpp returns when `pRoom` is NULL). In the game a room activated earlier already has its
active room, so the seam merge of a later room also updates that room's collision grid. That does
not change the DRLG tile data or the RNG.
- Unit spawning and `DRLG_CreateRoomForRoomEx` are outside the oracle.
## Output (schema 2)
## Output (schema 3)
```text
{schema: 2, oracle: {d2moo, arch: "i386"}, gameSeed, difficulty, isolated,
{schema: 3, oracle: {d2moo, arch: "i386"}, gameSeed, difficulty, isolated,
drlg: {startSeed, seed: [low, high]},
act: [{id, drlgType, levelType, coord: [x, y, w, h], flags, seed,
outdoor?: {flags, roomData: [orth]}, preset?: {direction, map}}], // sorted by id
@ -93,11 +114,34 @@ is implemented with real data, and everything else **fails fast** (exit code 2).
preset?: {prest, picked, flags, map}}], // creation order
maps: [{prest, picked, coord, file, hasInfo, units}],
levelSeedAfterRooms, warp: {n, xy},
activation: [{seed, tile, wall, floor, dirt} | // outdoor rooms
{seed, walls: [grid], tiles: [grid], floors: [grid], shadow}, // preset rooms
... units]}]}
activation: [{seed, tile, wall, floor, dirt, units, seedAfterTiles, mapTiles} | // outdoor rooms
{seed, walls: [grid], tiles: [grid], floors: [grid], shadow, units, // preset rooms
seedAfterTiles, mapTiles}]}]}
mapTiles: {nWalls, nFloors, nShadows, // pTileGrid counters
pTiles: {nWalls, nFloors, nRoofs}, // pTileGrid->pTiles counters
walls: [tile], floors: [tile], roofs: [tile],// pTiles arrays, exactly pTiles.nX entries each
links: [[bFloor, tileRef]], // pTileGrid->pMapLinks chain
warps: [[[levelId, room] | null, lvlWarpRow, bEnabled, tileRef, tileRef]], // pRoomTiles chain
units, // pPresetUnits (incl. AddWarp's UNIT_TILE units)
logical: null | {flags, nLists, indexX: grid, indexY: grid, lists: [coordList]}}
tile: [nWidth, nHeight, nPosX, nPosY, unk0x10, dwFlags, pTile, nTileType, unk0x20, unk0x24,
nRed, nGreen, nBlue, nIntensity, unk0x2C] // every D2DrlgTileDataStrc field in order
```
- The activation fields up to `units` are captured right after `DRLGROOMTILE_InitRoomGrids`;
`seedAfterTiles` is the room seed right after the room's own `DRLGROOMTILE_AddRoomMapTiles`.
- `mapTiles` is the final state after every requested room was activated: rooms activated later
rewrite tiles and preset units of the near rooms activated before them.
- References: a `D2DrlgTileDataStrc*` (`tileRef`, `unk0x20`) is `[levelId, room, "w"|"f"|"r", index]`
into that room's `pWallTiles` / `pFloorTiles` / `pRoofTiles` (rooms numbered in `pFirstRoomEx`
order, any level of the act); `pTile` is `[DT1 path (lower case, "/"-separated), tile index in that
DT1]`; `unk0x10` is an int, or `[levelId, room, "c", index]` when `DRLGLOGIC` stored a
`D2RoomCoordListStrc*` in it (index in that room's `pCoordList` chain). `lvlWarpRow` is the
LvlWarp.txt row of `pLvlWarpTxtRecord` (-1 for NULL). `coordList` is
`[nIndex, bNode, bRoomActive, box0, box1]`; `indexY` cells are 1 + the index of the list in `lists`
(0 for NULL).
- Shadow tiles live in `pRoofTiles` (`DRLGROOMTILE_CountAllTileTypes` counts them into `nRoofs`).
- `grid` is `{w, h, cells: [row-major int32]}`, or `null` when the grid was never allocated.
- `vertex` is `[x, y, direction, flags, next]`. `next` is -1 for null, 0..23 for `pVertices[i]`,
and 100+k for the k-th ring vertex.

View File

@ -4,13 +4,18 @@
# Environment:
# D2MOO_SRC D2MOO checkout (default: ~/tmp/D2MOO), must be at the pinned commit below.
# CXX C++ compiler (default: g++); needs 32-bit support (-m32, gcc-multilib).
# OUT install directory of the binary (default: tools/d2moo-oracle/build).
#
# Objects are compiled in a private temporary directory and the binary is installed with an atomic
# rename ($OUT/d2moo-oracle.tmp -> $OUT/d2moo-oracle), so an oracle that is running concurrently is
# never deleted or replaced by a half-written file.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
D2MOO_SRC="${D2MOO_SRC:-$HOME/tmp/D2MOO}"
PINNED_COMMIT=5596f5cb6c5251a0a07c6637d26458b06099d516
CXX="${CXX:-g++}"
OUT="$HERE/build"
OUT="${OUT:-$HERE/build}"
actual_commit="$(git -C "$D2MOO_SRC" rev-parse HEAD)"
if [[ "$actual_commit" != "$PINNED_COMMIT" ]]; then
@ -32,8 +37,9 @@ D2MOO_SOURCES=("$D2MOO_SRC"/source/D2Common/src/Drlg/*.cpp
"$D2MOO_SRC/source/D2Common/src/DataTbls/LevelsTbls.cpp")
ORACLE_SOURCES=("$HERE"/src/*.cpp)
rm -rf "$OUT"
mkdir -p "$OUT/obj/d2moo" "$OUT/obj/oracle"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/d2moo-oracle-build.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/obj/d2moo" "$WORK/obj/oracle"
compile() {
local src="$1" obj="$2"
@ -44,14 +50,14 @@ export CXX
pids=()
d2moo_objs=()
for src in "${D2MOO_SOURCES[@]}"; do
obj="$OUT/obj/d2moo/$(basename "${src%.cpp}").o"
obj="$WORK/obj/d2moo/$(basename "${src%.cpp}").o"
d2moo_objs+=("$obj")
compile "$src" "$obj" &
pids+=($!)
done
oracle_objs=()
for src in "${ORACLE_SOURCES[@]}"; do
obj="$OUT/obj/oracle/$(basename "${src%.cpp}").o"
obj="$WORK/obj/oracle/$(basename "${src%.cpp}").o"
oracle_objs+=("$obj")
compile "$src" "$obj" &
pids+=($!)
@ -70,5 +76,9 @@ for obj in "${d2moo_objs[@]}"; do
done
WRAP_SYMBOL=_Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc
"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "-Wl,--wrap=$WRAP_SYMBOL" -o "$OUT/d2moo-oracle"
"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "-Wl,--wrap=$WRAP_SYMBOL" -o "$WORK/d2moo-oracle"
mkdir -p "$OUT"
cp "$WORK/d2moo-oracle" "$OUT/d2moo-oracle.tmp"
mv -f "$OUT/d2moo-oracle.tmp" "$OUT/d2moo-oracle"
echo "built $OUT/d2moo-oracle (D2MOO $PINNED_COMMIT)"

View File

@ -1,5 +1,5 @@
// D2MOO DRLG oracle driver: generates Act I levels with the native D2MOO code and dumps every
// stage the TypeScript port is diffed against (schema v2). See ../README.md.
// stage the TypeScript port is diffed against (schema v3). See ../README.md.
//
// Usage:
// d2moo-oracle --data <dir> --seed <n> --levels 2,3,4 [--difficulty 0] [--isolated]
@ -11,6 +11,7 @@
#include "D2DataTbls.h"
#include "Drlg/D2DrlgDrlg.h"
#include "Drlg/D2DrlgDrlgGrid.h"
#include "Drlg/D2DrlgDrlgLogic.h"
#include "Drlg/D2DrlgDrlgRoom.h"
#include "Drlg/D2DrlgDrlgVer.h"
#include "Drlg/D2DrlgOutdoors.h"
@ -20,9 +21,11 @@
#include "D2Seed.h"
#include <DataTbls/LevelsIds.h>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <malloc.h>
#include <map>
#include <sstream>
#include <string>
@ -289,16 +292,30 @@ std::string Rooms(D2DrlgLevelStrc* pLevel, std::vector<D2DrlgMapStrc*>& maps)
return o.str();
}
// ---- activation stage ----
struct RoomActivation
{
D2DrlgRoomStrc* pRoom;
// Schema-2 fields, captured right after DRLGROOMTILE_InitRoomGrids (JSON object without the closing brace).
std::string szHead;
// Room seed right after the room's own DRLGROOMTILE_AddRoomMapTiles.
std::string szSeedAfterTiles;
};
// Mirrors DRLGACTIVATE_InitializeRoomEx (DrlgActivate.cpp:317-331) followed by
// DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-105) up to InitRoomGrids. Tile selection
// (DRLGROOMTILE_AddRoomMapTiles) needs the DT1 libraries and is outside the oracle.
// DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-115) up to DRLGROOMTILE_AddRoomMapTiles.
// DRLG_CreateRoomForRoomEx (the D2ActiveRoomStrc, built by D2Game) is not run, so pRoom stays NULL for
// every room and D2Common_COLLISION_FirstFn_6FD41000 is a no-op (D2Collision.cpp: returns when pRoom is
// NULL). In the game a room activated earlier already has its active room, so the linked-tile merge of
// a later room also updates that room's collision grid (no effect on the DRLG tile data or the RNG).
// Rooms are activated in pFirstRoomEx order; a preset map spanning several rooms loads its DS1 with
// the seed of the first activated room, which only matters for the randomly skipped preset units
// of DRLGPRESET_AddPresetUnitToDrlgMap (none of them occur in Act I outdoor DS1s).
std::string Activate(D2DrlgLevelStrc* pLevel)
// of DRLGPRESET_AddPresetUnitToDrlgMap (none of them occur in Act I outdoor DS1s). The activation
// order also decides the room-seam merge of DRLGROOMTILE_GetCreateLinkedTileData: the room activated
// later edits the tiles of the near rooms activated before it.
std::vector<RoomActivation> Activate(D2DrlgLevelStrc* pLevel)
{
std::ostringstream o;
o << "[";
std::vector<RoomActivation> out;
int i = 0;
char szContext[64];
for (D2DrlgRoomStrc* pRoom = pLevel->pFirstRoomEx; pRoom; pRoom = pRoom->pDrlgRoomNext, ++i)
@ -323,7 +340,7 @@ std::string Activate(D2DrlgLevelStrc* pLevel)
}
DRLGROOMTILE_InitRoomGrids(pRoom);
if (i) o << ",";
std::ostringstream o;
o << "{\"seed\":" << Seed(pRoom->pSeed);
if (pRoom->nType == DRLGTYPE_MAZE)
{
@ -343,9 +360,279 @@ std::string Activate(D2DrlgLevelStrc* pLevel)
for (int l = 0; l < pFile->nFloorLayers; ++l) o << (l ? "," : "") << Grid(p->pFloorGrid[l]);
o << "],\"shadow\":" << Grid(p->pCellGrid);
}
o << ",\"units\":" << PresetUnits(pRoom->pPresetUnits) << "}";
o << ",\"units\":" << PresetUnits(pRoom->pPresetUnits);
DRLGROOMTILE_AddRoomMapTiles(pRoom);
out.push_back({pRoom, o.str(), Seed(pRoom->pSeed)});
}
o << "]";
return out;
}
// ---- mapTiles (schema 3): the room's D2DrlgTileGridStrc after every requested room is activated ----
// Pointers are dumped as references, resolved over every room of the act (pDrlg->pLevel chain, rooms
// numbered in pFirstRoomEx order), because warps and linked tiles cross level seams:
// D2DrlgTileDataStrc* -> [levelId, room, "w"|"f"|"r", index] into that room's pTileGrid->pTiles
// pWallTiles / pFloorTiles / pRoofTiles (the pool block, not only [0, count))
// D2RoomCoordListStrc* -> [levelId, room, "c", index in that room's pLogicalRoomInfo->pCoordList chain]
// D2DrlgRoomStrc* -> [levelId, room]
struct TileBlock
{
int nLevelId;
int nRoom;
char cArray;
size_t nBytes;
};
struct RefIndex
{
std::map<uintptr_t, TileBlock> tileBlocks;
std::map<const D2RoomCoordListStrc*, std::string> coordLists;
std::map<const D2DrlgRoomStrc*, std::string> rooms;
};
constexpr int kMaxChain = 1 << 20;
void AddTileBlock(RefIndex& index, const D2DrlgTileDataStrc* pTiles, int nLevelId, int nRoom, char cArray)
{
if (!pTiles)
{
return;
}
// FOG_AllocPool / FOG_ReallocPool are calloc / realloc, so a pool block is a malloc block.
const TileBlock block = {nLevelId, nRoom, cArray, malloc_usable_size((void*)pTiles)};
if (!index.tileBlocks.emplace((uintptr_t)pTiles, block).second)
{
D2ORACLE_Fail("level %d room %d: tile array %c starts at another array's address", nLevelId, nRoom, cArray);
}
}
RefIndex BuildRefIndex(D2DrlgStrc* pDrlg)
{
RefIndex index;
for (D2DrlgLevelStrc* pLevel = pDrlg->pLevel; pLevel; pLevel = pLevel->pNextLevel)
{
int nRoom = 0;
for (D2DrlgRoomStrc* pRoom = pLevel->pFirstRoomEx; pRoom; pRoom = pRoom->pDrlgRoomNext, ++nRoom)
{
const std::string szRoom = std::to_string(pLevel->nLevelId) + "," + std::to_string(nRoom);
index.rooms[pRoom] = "[" + szRoom + "]";
if (const D2DrlgTileGridStrc* pTileGrid = pRoom->pTileGrid)
{
AddTileBlock(index, pTileGrid->pTiles.pWallTiles, pLevel->nLevelId, nRoom, 'w');
AddTileBlock(index, pTileGrid->pTiles.pFloorTiles, pLevel->nLevelId, nRoom, 'f');
AddTileBlock(index, pTileGrid->pTiles.pRoofTiles, pLevel->nLevelId, nRoom, 'r');
}
if (const D2DrlgLogicalRoomInfoStrc* pInfo = pRoom->pLogicalRoomInfo)
{
int k = 0;
for (const D2RoomCoordListStrc* p = pInfo->pCoordList; p; p = p->pNext, ++k)
{
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pCoordList chain does not terminate", pLevel->nLevelId, nRoom);
index.coordLists[p] = "[" + szRoom + ",\"c\"," + std::to_string(k) + "]";
}
}
}
}
return index;
}
std::string TileRef(const RefIndex& index, const D2DrlgTileDataStrc* p)
{
if (!p)
{
return "null";
}
auto it = index.tileBlocks.upper_bound((uintptr_t)p);
if (it != index.tileBlocks.begin())
{
--it;
const uintptr_t nOffset = (uintptr_t)p - it->first;
if (nOffset < it->second.nBytes)
{
if (nOffset % sizeof(D2DrlgTileDataStrc))
{
D2ORACLE_Fail("tile pointer %p is not an element of level %d room %d array %c", (const void*)p, it->second.nLevelId, it->second.nRoom, it->second.cArray);
}
return "[" + std::to_string(it->second.nLevelId) + "," + std::to_string(it->second.nRoom) + ",\"" + it->second.cArray + "\","
+ std::to_string(nOffset / sizeof(D2DrlgTileDataStrc)) + "]";
}
}
D2ORACLE_Fail("tile pointer %p is outside every tile array of the act (dangling?)", (const void*)p);
}
// D2DrlgTileDataStrc::unk0x10 holds (int)D2RoomCoordListStrc* when DRLGLOGIC set it (-m32), else an int.
std::string TileUnk10(const RefIndex& index, int32_t nValue)
{
if (nValue)
{
auto it = index.coordLists.find((const D2RoomCoordListStrc*)(uintptr_t)(uint32_t)nValue);
if (it != index.coordLists.end())
{
return it->second;
}
}
return std::to_string(nValue);
}
std::string TileEntry(const D2TileLibraryEntryStrc* pTile)
{
if (!pTile)
{
return "null";
}
std::string szLibrary;
int nIndex = -1;
D2ORACLE_DescribeTile(pTile, &szLibrary, &nIndex);
if (szLibrary.find_first_of("\"\\") != std::string::npos)
{
D2ORACLE_Fail("tile library path %s needs JSON escaping", szLibrary.c_str());
}
return "[\"" + szLibrary + "\"," + std::to_string(nIndex) + "]";
}
// Every D2DrlgTileDataStrc field in struct order (D2DrlgDrlg.h:333).
std::string TileData(const RefIndex& index, const D2DrlgTileDataStrc& t)
{
std::ostringstream o;
o << "[" << t.nWidth << "," << t.nHeight << "," << t.nPosX << "," << t.nPosY << "," << TileUnk10(index, t.unk0x10) << "," << t.dwFlags
<< "," << TileEntry(t.pTile) << "," << t.nTileType << "," << TileRef(index, t.unk0x20) << "," << t.unk0x24 << "," << (int)t.nRed
<< "," << (int)t.nGreen << "," << (int)t.nBlue << "," << (int)t.nIntensity << "," << t.unk0x2C << "]";
return o.str();
}
std::string TileDataArray(const RefIndex& index, const D2DrlgTileDataStrc* pTiles, int nTiles, const char* szArray, int nLevelId, int nRoom)
{
if (nTiles < 0 || (nTiles > 0 && !pTiles))
{
D2ORACLE_Fail("level %d room %d: %d %s without an array", nLevelId, nRoom, nTiles, szArray);
}
if (nTiles > 0 && (size_t)nTiles * sizeof(D2DrlgTileDataStrc) > malloc_usable_size((void*)pTiles))
{
D2ORACLE_Fail("level %d room %d: %d %s exceed their pool block", nLevelId, nRoom, nTiles, szArray);
}
std::string s = "[";
for (int i = 0; i < nTiles; ++i)
{
if (i) s += ",";
s += TileData(index, pTiles[i]);
}
return s + "]";
}
std::string CoordList(const D2RoomCoordListStrc& c)
{
std::ostringstream o;
o << "[" << c.nIndex << "," << c.bNode << "," << c.bRoomActive << "," << Coord(c.pBox[0]) << "," << Coord(c.pBox[1]) << "]";
return o.str();
}
// {flags, nLists, indexX, indexY, lists} or null. pIndexY cells hold (int)D2RoomCoordListStrc*
// (DRLGLOGIC_AssignCoordListsForGrids): dumped as 1 + the index of that list in the room's own
// pCoordList chain, 0 for NULL.
std::string LogicalRoomInfo(const D2DrlgRoomStrc* pRoom, int nLevelId, int nRoom)
{
const D2DrlgLogicalRoomInfoStrc* pInfo = pRoom->pLogicalRoomInfo;
if (!pInfo)
{
return "null";
}
std::map<uint32_t, int> chain;
std::string lists = "[";
int k = 0;
for (const D2RoomCoordListStrc* p = pInfo->pCoordList; p; p = p->pNext, ++k)
{
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pCoordList chain does not terminate", nLevelId, nRoom);
chain[(uint32_t)(uintptr_t)p] = k;
lists += (k ? "," : "") + CoordList(*p);
}
lists += "]";
std::ostringstream o;
o << "{\"flags\":" << pInfo->dwFlags << ",\"nLists\":" << pInfo->nLists << ",\"indexX\":" << Grid(pInfo->pIndexX) << ",\"indexY\":";
const D2DrlgGridStrc& grid = pInfo->pIndexY;
if (!grid.pCellsFlags || !grid.pCellsRowOffsets)
{
o << "null";
}
else
{
o << "{\"w\":" << grid.nWidth << ",\"h\":" << grid.nHeight << ",\"cells\":[";
for (int y = 0; y < grid.nHeight; ++y)
{
for (int x = 0; x < grid.nWidth; ++x)
{
const uint32_t nCell = (uint32_t)grid.pCellsFlags[x + grid.pCellsRowOffsets[y]];
int nValue = 0;
if (nCell)
{
auto it = chain.find(nCell);
if (it == chain.end()) D2ORACLE_Fail("level %d room %d: pIndexY cell (%d, %d) is not a list of the room's chain", nLevelId, nRoom, x, y);
nValue = it->second + 1;
}
o << ((x || y) ? "," : "") << nValue;
}
}
o << "]}";
}
o << ",\"lists\":" << lists << "}";
return o.str();
}
// pRoomTiles chain: [[levelId, room] of pDrlgRoom, pLvlWarpTxtRecord index (-1 for NULL), bEnabled,
// unk0x0C, unk0x10].
std::string RoomTiles(const RefIndex& index, const D2DrlgRoomStrc* pRoom, int nLevelId, int nRoom)
{
std::string s = "[";
int k = 0;
for (const D2RoomTileStrc* p = pRoom->pRoomTiles; p; p = p->pNext, ++k)
{
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pRoomTiles chain does not terminate", nLevelId, nRoom);
std::string szRoom = "null";
if (p->pDrlgRoom)
{
auto it = index.rooms.find(p->pDrlgRoom);
if (it == index.rooms.end()) D2ORACLE_Fail("level %d room %d: warp %d leads to a room outside the act", nLevelId, nRoom, k);
szRoom = it->second;
}
int nWarp = -1;
if (p->pLvlWarpTxtRecord)
{
const ptrdiff_t n = p->pLvlWarpTxtRecord - sgptDataTables->pLvlWarpTxt;
if (n < 0 || n >= sgptDataTables->nLvlWarpTxtRecordCount) D2ORACLE_Fail("level %d room %d: warp %d has a record outside LvlWarp.txt", nLevelId, nRoom, k);
nWarp = (int)n;
}
s += (k ? "," : "");
s += "[" + szRoom + "," + std::to_string(nWarp) + "," + std::to_string(p->bEnabled) + "," + TileRef(index, p->unk0x0C) + "," + TileRef(index, p->unk0x10) + "]";
}
return s + "]";
}
std::string MapTiles(const RefIndex& index, const D2DrlgRoomStrc* pRoom, int nLevelId, int nRoom)
{
const D2DrlgTileGridStrc* pTileGrid = pRoom->pTileGrid;
if (!pTileGrid)
{
D2ORACLE_Fail("level %d room %d: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles", nLevelId, nRoom);
}
if (pTileGrid->pAnimTiles)
{
D2ORACLE_Fail("level %d room %d: animated tiles (pAnimTiles) are not dumped", nLevelId, nRoom);
}
const D2DrlgRoomTilesStrc& t = pTileGrid->pTiles;
std::ostringstream o;
o << "{\"nWalls\":" << pTileGrid->nWalls << ",\"nFloors\":" << pTileGrid->nFloors << ",\"nShadows\":" << pTileGrid->nShadows
<< ",\"pTiles\":{\"nWalls\":" << t.nWalls << ",\"nFloors\":" << t.nFloors << ",\"nRoofs\":" << t.nRoofs << "}"
<< ",\"walls\":" << TileDataArray(index, t.pWallTiles, t.nWalls, "walls", nLevelId, nRoom)
<< ",\"floors\":" << TileDataArray(index, t.pFloorTiles, t.nFloors, "floors", nLevelId, nRoom)
<< ",\"roofs\":" << TileDataArray(index, t.pRoofTiles, t.nRoofs, "roofs", nLevelId, nRoom) << ",\"links\":[";
int k = 0;
for (const D2DrlgTileLinkStrc* p = pTileGrid->pMapLinks; p; p = p->pNext, ++k)
{
if (k >= kMaxChain) D2ORACLE_Fail("level %d room %d: pMapLinks chain does not terminate", nLevelId, nRoom);
o << (k ? "," : "") << "[" << p->bFloor << "," << TileRef(index, p->pMapTile) << "]";
}
o << "],\"warps\":" << RoomTiles(index, pRoom, nLevelId, nRoom) << ",\"units\":" << PresetUnits(pRoom->pPresetUnits)
<< ",\"logical\":" << LogicalRoomInfo(pRoom, nLevelId, nRoom) << "}";
return o.str();
}
@ -459,14 +746,14 @@ int main(int argc, char** argv)
D2DrlgActStrc act = {};
std::ostringstream o;
o << "{\"schema\":2,\"oracle\":{\"d2moo\":\"" << D2ORACLE_D2MOO_COMMIT << "\",\"arch\":\"i386\"},\"gameSeed\":" << nSeed
o << "{\"schema\":3,\"oracle\":{\"d2moo\":\"" << D2ORACLE_D2MOO_COMMIT << "\",\"arch\":\"i386\"},\"gameSeed\":" << nSeed
<< ",\"difficulty\":" << nDifficulty << ",\"isolated\":" << (bIsolated ? "true" : "false");
D2DrlgStrc* pDrlg = AllocAct1(&act, nSeed, (uint8_t)nDifficulty);
o << ",\"drlg\":{\"startSeed\":" << pDrlg->dwStartSeed << ",\"seed\":" << Seed(pDrlg->pSeed) << "},\"act\":" << Act(pDrlg) << ",\"levels\":[";
std::vector<std::string> heads(levels.size());
std::vector<D2DrlgLevelStrc*> generated(levels.size(), nullptr);
std::vector<std::string> activations(levels.size());
std::vector<std::vector<RoomActivation>> activations(levels.size());
if (bIsolated)
{
// A fresh act per level proves level generation does not depend on other levels.
@ -493,9 +780,26 @@ int main(int argc, char** argv)
activations[i] = Activate(generated[i]);
}
}
// "mapTiles" is serialised only now: a room activated later rewrites tiles of the near rooms
// activated before it and rolls their seeds (DRLGROOMTILE_GetCreateLinkedTileData ->
// DRLGROOMTILE_LinkedTileDataManager), also across level seams, so the tile data are the final state.
std::map<D2DrlgStrc*, RefIndex> refIndices;
for (size_t i = 0; i < levels.size(); ++i)
{
o << (i ? "," : "") << heads[i] << ",\"activation\":" << activations[i] << "}";
D2DrlgStrc* pLevelDrlg = generated[i]->pDrlg;
auto it = refIndices.find(pLevelDrlg);
if (it == refIndices.end())
{
it = refIndices.emplace(pLevelDrlg, BuildRefIndex(pLevelDrlg)).first;
}
o << (i ? "," : "") << heads[i] << ",\"activation\":[";
for (size_t k = 0; k < activations[i].size(); ++k)
{
const RoomActivation& a = activations[i][k];
o << (k ? "," : "") << a.szHead << ",\"seedAfterTiles\":" << a.szSeedAfterTiles << ",\"mapTiles\":"
<< MapTiles(it->second, a.pRoom, generated[i]->nLevelId, (int)k) << "}";
}
o << "]}";
}
o << "]}\n";

View File

@ -19,3 +19,9 @@ void D2ORACLE_CloseTrace();
void D2ORACLE_SetTraceContext(const char* szContext);
// Writes one free-form event line to the trace (no-op when tracing is off).
void D2ORACLE_TraceLine(const char* szFormat, ...) __attribute__((format(printf, 1, 2)));
struct D2TileLibraryEntryStrc;
// Identity of a tile returned by the D2CMP shims: the owning DT1 (lower-case, '/'-separated path
// relative to the data root, e.g. "data/global/tiles/act1/outdoors/blank.dt1") and the tile's index in
// that DT1's tile header table. Fails for pointers the shims did not hand out.
void D2ORACLE_DescribeTile(const D2TileLibraryEntryStrc* pTileLibraryEntry, std::string* pszLibrary, int* pnIndex);

View File

@ -20,6 +20,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <malloc.h>
#include <map>
#include <string>
#include <vector>
@ -127,9 +128,25 @@ void __fastcall FOG_FreePool(void*, void* pFree, const char*, int, int)
{
free(pFree);
}
// D2_REALLOC_POOL leaves the grown bytes uninitialised in the game. The only caller on the DRLG path,
// DRLGROOMTILE_ReallocRoofTileGrid, never initialises unk0x10 / nIntensity / unk0x2C of the appended
// roof tiles, so the oracle zero-fills everything past the old block (the TypeScript port appends
// zeroed elements). FOG_AllocPool is calloc, which clears the whole usable size, so by induction every
// byte past the requested size is zero.
void* __fastcall FOG_ReallocPool(void*, void* pMem, int nSize, const char*, int, int)
{
return realloc(pMem, nSize > 0 ? nSize : 1);
const size_t nOldUsable = pMem ? malloc_usable_size(pMem) : 0;
void* pNew = realloc(pMem, nSize > 0 ? nSize : 1);
if (!pNew)
{
D2ORACLE_Fail("FOG_ReallocPool(%d) failed", nSize);
}
const size_t nNewUsable = malloc_usable_size(pNew);
if (nNewUsable > nOldUsable)
{
memset((char*)pNew + nOldUsable, 0, nNewUsable - nOldUsable);
}
return pNew;
}
void* __fastcall FOG_Alloc(int nSize, const char*, int, int)
{
@ -330,19 +347,27 @@ BOOL __fastcall MONSTERS_ValidateMonsterId(int)
// DRLGROOMTILE_GetTileCache (DrlgRoomTile.cpp:45-92) rolls the room seed iff the rarity sum of the
// tiles returned by D2CMP_10088_GetTiles is > 0, and it runs inside the LvlSub stamping of
// InitOutdoorRoomGrids (sub_6FD8ACE0 -> DRLGROOMTILE_InitTileShadow), so the tile data advances the
// substitution RNG stream. Only the per-query tile count and rarity sum influence the dumped grids.
// The ORDER of the returned entries (slot order, then DT1 file order) is not verified against
// D2CMP.dll; it only decides which tile variant GetTileCache picks, which the oracle does not dump.
// substitution RNG stream. Since schema 3 the chosen entry itself is dumped (D2DrlgTileDataStrc::pTile
// as [library path, tile index in that DT1]), so the ORDER of the returned entries (slot order, then DT1
// file order) decides dumped output: which variant DRLGROOMTILE_GetTileCache picks for a given roll.
// That order is NOT verified against the real D2CMP.dll (open question; the returned count and rarity
// sum do not depend on it).
namespace
{
constexpr size_t kDt1HeaderSize = 0x114;
constexpr size_t kDt1TileHeaderSize = 96;
constexpr int kTileSlots = 32; // D2DrlgRoomStrc::pTiles / D2DrlgStrc::pTiles
struct OracleTileLibrary;
// POD: allocated with new + memset(0). pLibrary / nIndex are the oracle's identity of the entry
// (not D2CMP fields), used only by D2ORACLE_DescribeTile.
struct OracleTile
{
D2TileLibraryEntryStrc tEntry;
uint8_t nSubtileFlags[25];
const OracleTileLibrary* pLibrary;
int nIndex;
};
struct OracleTileLibrary
@ -415,6 +440,8 @@ OracleTileLibrary* LoadTileLibrary(const char* szFileName)
pTile->tEntry.nSequence = ReadLE<int32_t>(bytes, nBase + 0x1C);
pTile->tEntry.nRarity_Frame = ReadLE<int32_t>(bytes, nBase + 0x20);
memcpy(pTile->nSubtileFlags, bytes.data() + nBase + 0x28, sizeof(pTile->nSubtileFlags));
pTile->pLibrary = pLibrary;
pTile->nIndex = i;
pLibrary->tiles.push_back(pTile);
gTilesByEntry[&pTile->tEntry] = pTile;
}
@ -434,6 +461,13 @@ OracleTile* TileOf(const D2TileLibraryEntryStrc* pEntry)
}
} // namespace
void D2ORACLE_DescribeTile(const D2TileLibraryEntryStrc* pTileLibraryEntry, std::string* pszLibrary, int* pnIndex)
{
const OracleTile* pTile = TileOf(pTileLibraryEntry);
*pszLibrary = pTile->pLibrary->szPath;
*pnIndex = pTile->nIndex;
}
//D2Cmp.#10087: stores the library in the first free slot of the 32-slot array.
void __stdcall D2CMP_10087_LoadTileLibrarySlot(D2TileLibraryHashStrc** ppTileLibraryHash, const char* szFileName)
{

View File

@ -17,11 +17,7 @@
},
"include": ["src/**/*.ts", "scripts/**/*.ts", "vite.config.ts", "tests/**/*.test.ts"],
"exclude": [
"src/game/drlg",
"scripts/drlg-diff.ts",
"scripts/lib/drlg-oracle.ts",
"tests/e2e-drop-parity",
"tests/fail-fast-drop.test.ts",
"tests/drlg-act1-oracle.test.ts"
"tests/fail-fast-drop.test.ts"
]
}