/** * 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 = 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 /** 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> = { [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, 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() const used = new Set() 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() 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 = (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() 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() 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() 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(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() 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(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, } }