806 lines
36 KiB
TypeScript
806 lines
36 KiB
TypeScript
/**
|
|
* 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()
|