/** * DS1 objects: `(type, id)` → the art the engine would draw, and where it lands. * * This is a port of three things out of ThePhrozenKeep/D2MOO (a C++ * re-implementation of Diablo II 1.10f), which is the reference this project * reads instead of guessing: * * 1. **`D2Common_10884_COMPOSIT_unk`** — `source/D2Common/src/D2Composit.cpp`, * D2Common ordinal **10884**, address `D2Common.0x6FD466C0`. It is the * function that decides which composition file an object's frame comes from. * For `UNIT_OBJECT` it takes the **class token** from * `DATATBLS_GetObjModeTypeTxtRecord(nClass, 0)->szToken` (the `objtype.txt` * row for the object's `Objects.txt` id), the **mode token** from * `DATATBLS_GetObjModeTypeTxtRecord(nMode, 1)->szToken` (the `objmode.txt` row * for the object's animation mode), the weapon class from * `COMPOSIT_GetWeaponClassCode` (always `' hth'` for objects), sets * `szPathPrefix = "DATA\\GLOBAL\\OBJECTS"`, strips every space byte with the * `c &= ((c == ' ') - 1)` idiom, and finally: * * wsprintfA(szPath, "%s\\%s\\COF\\%s%s%s.COF", prefix, class, class, mode, weapon) * * i.e. `data\global\objects\\COF\hth.COF`. That is the * whole member-selection rule: **there is no per-object table of art files** — * the object's `Token` column and its *animation mode* are concatenated onto a * fixed path template, and a COF is what gets loaded. * 2. **The COF's layers pick the sprite files** — `decodeCof` reads them. A COF * layer record (9 bytes, starting at file offset `28 + 9 * layer`, see * `D2Comp.cpp` `sub_6F8A1250`, which walks `v12 += 9` and reads a 3-byte * weapon token at `+5`) carries a *composite index*, not a file name. The * composite index maps to a component directory through `D2Composit.h`'s * `COMPOSIT_HEAD, COMPOSIT_TORSO, ... COMPOSIT_SPECIAL1, ...` order. The * sprite's own file name is reconstructed from the same tokens as the COF * name, plus the object's fixed armor class `lit`: * `data\global\objects\\\lithth.dcc`. * Archive evidence: sweeping all 1461 object COFs in `d2data.mpq` + * `d2exp.mpq` and rebuilding every layer's path this way resolves 1728 layers * to an existing `.dcc` by exact name; the remaining 18 are the same name * with a `.dc6` extension (the `1y`, `4x`, `e1`, `e2`, `tp`, `ho`, `yq` * tokens ship as DC6 in `d2exp.mpq`). Nothing else was tried and nothing else * was needed, so the rule is exact, not approximate. * 3. **The draw position** — `DUNGEON_GameToClientTileDrawPositionCoords` and * `DUNGEON_GameToClientSubtileDrawPositionCoords` * (`source/D2Common/src/D2Dungeon.cpp`, ordinals **10115** and **10117**), * documented in `doc/Coordinates.md`. They are ported verbatim under * {@link tileDrawPositionCoords} / {@link subtileDrawPositionCoords}, and * {@link objectDrawAnchor} is the object case of the latter. * * Two facts about modes that the art rule depends on: * * - The mode index is D2's object animation mode, `D2Common/include/DataTbls/ObjectsIds.h` * `enum D2C_ObjModes`: `OBJMODE_NEUTRAL=0` (`NU`), `OBJMODE_OPERATING=1` (`OP`), * `OBJMODE_OPENED=2` (`ON`), `OBJMODE_SPECIAL1..5=3..7` (`S1`..`S5`). * - A freshly placed map object is in `OBJMODE_NEUTRAL`: * `source/D2Game/src/OBJECTS/Objects.cpp` calls * `UNITS_ChangeAnimMode(pObject, OBJMODE_NEUTRAL)`. `Objects.txt`'s per-mode * `Mode0..Mode7` columns (a 0/1 flag array, `D2ObjectsTxt::nMode[8]` at * `0x13F`, mapped in `source/D2Common/src/DataTbls/ObjectsTbls.cpp`) say which * modes an object actually has art for: casket `C5` has modes `NU`/`OP`/`ON` * set and ships exactly `C5NUHTH.COF`, `c5ophth.cof`, `c5onhth.cof`; the door * `D1` has all seven set and ships all seven COFs. */ import type { D2Table } from './acts.ts' import { cell, parseTable } from './acts.ts' import { cofLayerOrder, decodeCof } from '../formats/cof.ts' import type { CofFile } from '../formats/cof.ts' import { decodeDcc } from '../formats/dcc.ts' import type { DccFile } from '../formats/dcc.ts' import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts' import type { MountedArchives } from '../mpq/mount.ts' import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts' import type { ObjectLookupEntry } from './object-lookup.ts' /** `Objects.txt` inside the archives. */ const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt' /** Prefix every object composition and sprite lives under (`szPathPrefix`). */ export const OBJECT_ROOT = 'data\\global\\objects\\' /** Prefix every monster composition and sprite lives under. */ export const MONSTER_ROOT = 'data\\global\\monsters\\' /** Weapon-class token the engine hard-codes for objects (`COMPOSIT_GetWeaponClassCode`). */ const OBJECT_WEAPON = 'hth' /** Armor-class token objects always use; characters vary it (`lit`/`med`/`hvy`). */ const OBJECT_ARMOR_CLASS = 'lit' /** How many animation modes `D2ObjectsTxt::nMode[8]` and `D2C_ObjModes` define. */ export const OBJECT_MODE_COUNT = 8 /** * Animation-mode index → the token `objmode.txt` gives that row. * * `D2C_ObjModes` in `DataTbls/ObjectsIds.h` names them in the comments; the * tokens are what the archive actually uses (`C5NUHTH.COF`, `c5ophth.cof`, * `d1s2hth.cof`). The order is load-bearing: it is the index the engine passes * to `DATATBLS_GetObjModeTypeTxtRecord(nMode, 1)`. */ export const OBJECT_MODE_TOKENS = ['NU', 'OP', 'ON', 'S1', 'S2', 'S3', 'S4', 'S5'] as const /** * Composite index → the archive's directory for that component. * * The same 16-entry order as `D2Composit.h` and as `Objects.txt`'s * `HD,TR,LG,RA,LA,RH,LH,SH,S1..S8` columns, which are the per-object flags * saying which of these the object's composition uses. Only `HD`, `TR` and * `S1..S6` appear under `data\global\objects\`, which matches those columns * being zero for every other entry across the whole table. */ export const OBJECT_COMPONENTS = [ 'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', ] as const /** * Component preference when only a member-name list is available. * * `tr` is the object's body layer: of the 1461 shipped object COFs, 1453 declare * component 1 (`tr`) as their first layer, 5 declare component 0 (`hd`) and 3 * declare component 8 (`s1`). The rest of the order is the composite order, so a * token that only ships `hd` art still resolves. When the COF itself is readable * use {@link loadObjectSheet}, which follows the COF's own declared layers and * draw order instead of this preference. */ const COMPONENT_PREFERENCE = ['tr', 'hd', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 'lg', 'ra', 'la', 'rh', 'lh', 'sh'] as const /** Sub-tile width in client pixels: `DUNGEON_GameSubtileToClientCoords` step on x (16). */ export const SUBTILE_WIDTH = 16 /** Sub-tile height in client pixels: the same function's step on y (8). */ export const SUBTILE_HEIGHT = 8 /** Floor-tile width in client pixels: `DUNGEON_GameTileToClientCoords` step on x (80). */ export const TILE_WIDTH = 80 /** Floor-tile height in client pixels: the same function's step on y (40). */ export const TILE_HEIGHT = 40 /** * `DUNGEON_GameTileToClientCoords` — D2Common ordinal 10110 (`0x6FD8D6E0`). * * Tile precision → client pixels, the *centre-line* conversion: a tile's client * centre. `doc/Coordinates.md` records it as `clientX = (gameX - gameY) / 2`, * `clientY = (gameX + gameY) / 4` for unit precision; at tile precision D2MOO * multiplies by the 160×80 pixel tile size and halves the y term. * * @param tileX - tile x. * @param tileY - tile y. * @returns client pixel x/y of the tile's centre. */ export function gameTileToClientCoords(tileX: number, tileY: number): { x: number; y: number } { return { x: TILE_WIDTH * (tileX - tileY), y: (TILE_WIDTH * (tileX + tileY)) / 2 } } /** * `DUNGEON_GameSubTileToClientCoords` — D2Common ordinal 10111 (`0x6FD8D630`). * * Sub-tile precision → client pixels, again the centre-line conversion: one * sub-tile is 32×16 px, so its centre steps by (±16, ±8). * * @param subX - sub-tile x (a DS1 cell is 5 sub-tiles). * @param subY - sub-tile y. * @returns client pixel x/y of the sub-tile's centre. */ export function gameSubtileToClientCoords(subX: number, subY: number): { x: number; y: number } { return { x: SUBTILE_WIDTH * (subX - subY), y: SUBTILE_HEIGHT * (subX + subY) } } /** * `DUNGEON_GameToClientCoords` — D2Common ordinal 10112 (`0x6FD8D660`). * * Unit precision, game → client, with no pixel scale at all: `(x - y) / 2`, * `(x + y) / 4`. Kept for completeness (it is what `doc/Coordinates.md`'s * headline formula describes) and to show why the draw-position functions are * the ones to use for pixels — this one is not in pixels. * * @param gameX - game-unit x. * @param gameY - game-unit y. * @returns client-unit x/y. */ export function gameToClientCoords(gameX: number, gameY: number): { x: number; y: number } { return { x: (gameX - gameY) / 2, y: (gameX + gameY) / 4 } } /** * `DUNGEON_GameToClientTileDrawPositionCoords` — D2Common ordinal 10115 (`0x6FD8D790`). * * The engine's tile draw position: where a floor *or* wall tile's art is placed. * `source/D2Common/src/D2Dungeon.cpp` `D2Common_10052` (ordinal 10052) feeds the * corners of a room into this same function to build the room's client rect, and * `doc/Coordinates.md` calls the result the render position of the source unit. * * Note the asymmetry against {@link gameTileToClientCoords}: x loses half a tile * width and y gains **a whole tile height**. The y term is not decoration — wall * bitmaps carry negative block offsets, and this `+80` is what puts their art * back over the cell. The project's `src/game/d2map.ts` reproduces the x term * exactly (`(cx - cy) * 80 - 80`) but not the y term; see * {@link objectDrawAnchor}'s module notes and `scripts/verify-objects.ts`, which * prints both side by side. * * @param tileX - tile x. * @param tileY - tile y. * @returns client pixel x/y the tile's bitmap is drawn at. */ export function tileDrawPositionCoords(tileX: number, tileY: number): { x: number; y: number } { return { x: TILE_WIDTH * (tileX - tileY) - TILE_WIDTH, y: TILE_HEIGHT * (tileX + tileY) + TILE_HEIGHT * 2 } } /** * `DUNGEON_GameToClientSubtileDrawPositionCoords` — D2Common ordinal 10117 (`0x6FD8D830`). * * The sub-tile draw position: half a sub-tile width to the left of the sub-tile * centre and one full sub-tile height below it. This is the function that places * an object, because a DS1 object is stored at sub-tile precision. * * The `-16`/`+16` constants are the *source square's* half-width and height, not * the sprite's: the engine has no frame-size term here at all. A caller that * centres by the frame's own width (as `src/game/d2map.ts` does for objects) is * therefore applying a rule the engine does not have. * * @param subX - sub-tile x. * @param subY - sub-tile y. * @returns client pixel x/y the sub-tile's art is drawn at. */ export function subtileDrawPositionCoords(subX: number, subY: number): { x: number; y: number } { return { x: SUBTILE_WIDTH * (subX - subY) - SUBTILE_WIDTH, y: SUBTILE_HEIGHT * (subX + subY) + SUBTILE_HEIGHT * 2 } } /** * `DUNGEON_ClientTileDrawPositionToGameCoords` — D2Common ordinal 10114 (`0x6FD8D710`). * * The inverse of {@link tileDrawPositionCoords}, including D2MOO's C-style * division: negative coordinates round towards **negative infinity** (`v / 160 - 1` * on the negative branch), not towards zero. A port that used `Math.trunc` would * be off by one tile on half the map. * * @param clientX - client pixel x from {@link tileDrawPositionCoords}. * @param clientY - client pixel y from the same. * @returns the tile coordinates. */ export function clientTileDrawPositionToGameCoords(clientX: number, clientY: number): { x: number; y: number } { return { x: floorDiv(2 * clientY + clientX, 160), y: floorDiv(2 * clientY - clientX, 160) } } /** * `DUNGEON_ClientSubileDrawPositionToGameCoords` — D2Common ordinal 10116 (`0x6FD8D7D0`). * * The sub-tile inverse of {@link subtileDrawPositionCoords}, with the same * floor-towards-negative-infinity division (divisor 32). * * @param clientX - client pixel x from {@link subtileDrawPositionCoords}. * @param clientY - client pixel y from the same. * @returns the sub-tile coordinates. */ export function clientSubtileDrawPositionToGameCoords(clientX: number, clientY: number): { x: number; y: number } { return { x: floorDiv(2 * clientY + clientX, 32), y: floorDiv(2 * clientY - clientX, 32) } } /** * Divide rounding towards negative infinity, as the C++ `p / n - 1` idiom does. * * @param value - numerator. * @param divisor - positive divisor. * @returns the floored quotient. */ function floorDiv(value: number, divisor: number): number { return value >= 0 ? Math.floor(value / divisor) : Math.floor(value / divisor) - 1 } /** * The anchor an object's frame is drawn at, relative to its sub-tile centre. * * The engine's answer is {@link subtileDrawPositionCoords}: a constant * `(-16, +16)` — half a sub-tile width left, one sub-tile height down — plus the * COF/DCC frame's own `xOffset`/`yOffset`. `tileWidth` and `tileHeight` are part * of the signature because every caller has them and because they are exactly * what the engine does *not* use: if a frame were positioned as * `(-width / 2, -height)`, a 32×32 barrel frame would land 32 px higher than the * engine puts it, and a 160-px-wide frame would be off by 64. They are validated * rather than read, so a caller that decoded nothing fails here instead of * silently emitting an anchor. * * @param tileWidth - the frame's width in pixels; must be finite and non-negative. * @param tileHeight - the frame's height in pixels; must be finite and non-negative. * @param frameOffsetX - the frame's own x offset (COF/DCC frame offset). * @param frameOffsetY - the frame's own y offset. * @returns the offset to add to `gameSubtileToClientCoords(object.x, object.y)`. */ export function objectDrawAnchor( tileWidth: number, tileHeight: number, frameOffsetX: number, frameOffsetY: number, ): { x: number; y: number } { if (!Number.isFinite(tileWidth) || tileWidth < 0) { throw new Error(`objectDrawAnchor: frame width ${String(tileWidth)} is not a finite non-negative size`) } if (!Number.isFinite(tileHeight) || tileHeight < 0) { throw new Error(`objectDrawAnchor: frame height ${String(tileHeight)} is not a finite non-negative size`) } if (!Number.isFinite(frameOffsetX) || !Number.isFinite(frameOffsetY)) { throw new Error(`objectDrawAnchor: frame offset (${String(frameOffsetX)}, ${String(frameOffsetY)}) is not finite`) } return { x: -SUBTILE_WIDTH + frameOffsetX, y: SUBTILE_HEIGHT * 2 + frameOffsetY } } /** * The anchor a DS1 object's art is drawn at, in absolute client pixels. * * `(DUNGEON_GameToClientSubtileDrawPositionCoords)` applied to the object's own * sub-tile position, with the frame offsets left at zero because the frame is not * known until the COF has been read. * * @param object - the DS1 object; `x`/`y` are sub-tiles. * @returns client pixel x/y of the object's draw position. */ export function objectAnchor(object: { readonly x: number; readonly y: number }): { x: number; y: number } { if (!Number.isFinite(object.x) || !Number.isFinite(object.y)) { throw new Error(`objectAnchor: object at (${String(object.x)}, ${String(object.y)}) is not finite`) } return subtileDrawPositionCoords(object.x, object.y) } /** One `Objects.txt` row, as the object-art rule needs it. */ export interface ObjectsRow { /** `Id` — the DS1 object entry's `id`, and the index `DATATBLS_GetObjectsTxtRecord` takes. */ readonly id: number /** `Name`, e.g. `Barrel`. */ readonly name: string /** `Token`, upper-cased: the `` in every path the engine builds. */ readonly token: string /** `SubClass` — the object class (`OBJSUBCLASS_DOOR` = 0x80, `CHEST` = 0x08, ...). */ readonly subClass: number /** `Act` the object belongs to (0 = any). */ readonly act: number /** `SizeX` / `SizeY` in sub-tiles, used for placement collision. */ readonly sizeX: number readonly sizeY: number /** `Xoffset` / `Yoffset`: the row's own art nudge, added to the frame anchor. */ readonly xOffset: number readonly yOffset: number /** `IsDoor` — doors take the operate path rather than the casket path. */ readonly isDoor: boolean /** `Trans` — transparency mode. */ readonly trans: number /** `Draw` — draw priority flag. */ readonly draw: number /** `TotalPieces` — how many components the object's composition has. */ readonly totalPieces: number /** `AutoMap` — automap cell/colour. */ readonly autoMap: number /** * `Mode0..Mode7`: 1 when the object has a composition for that animation mode. * * This is `D2ObjectsTxt::nMode[8]`, and it is the array D2MOO consults * (`pObjectsTxtRecord->nMode[1]`, `[2]`) to decide whether an operate action has * art to play. It also matches the shipped COF set exactly. */ readonly mode: readonly number[] /** `Selectable0..7`: whether the object is targetable while in that mode. */ readonly selectable: readonly number[] /** `FrameCnt0..7`: frames in that mode. */ readonly frameCnt: readonly number[] /** `FrameDelta0..7`: per-frame delay, 1/256 units (the engine shifts these left by 8). */ readonly frameDelta: readonly number[] /** `Start0..7`: the mode's first frame **within the COF**. */ readonly start: readonly number[] /** `CycleAnim0..7`: whether that mode loops. */ readonly cycleAnim: readonly number[] /** `Lit0..7`: whether the mode is drawn lit. */ readonly lit: readonly number[] /** `HD`, `TR`, `LG`, `RA`, `LA`, `RH`, `LH`, `SH`, `S1..S8`: composition components present. */ readonly components: readonly string[] } /** `Objects.txt` parsed once, plus the id lookup the DS1 decoder needs. */ export interface ObjectsTable { /** The raw parsed table, for callers that need a column not modelled here. */ readonly table: D2Table /** Every row, in file order (`Id` order). */ readonly rows: readonly ObjectsRow[] /** `Id` → row. */ readonly byId: ReadonlyMap } /** Columns of the 16 composition flags, in `D2ObjectsTxt` order. */ const COMPONENT_COLUMNS = ['HD', 'TR', 'LG', 'RA', 'LA', 'RH', 'LH', 'SH', 'S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8'] as const /** * Read one `Objects.txt` row. * * @param table - the parsed table. * @param row - the raw cells. * @returns the typed row. */ function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow { const numbers = (prefix: string, count: number): number[] => Array.from({ length: count }, (_, index) => Number(cell(table, row, `${prefix}${String(index)}`) || '0')) const components = COMPONENT_COLUMNS.filter(column => cell(table, row, column) !== '0' && cell(table, row, column) !== '') .map(column => column.toLowerCase()) return { id: Number(cell(table, row, 'Id')), name: cell(table, row, 'Name'), token: cell(table, row, 'Token').trim().toUpperCase(), subClass: Number(cell(table, row, 'SubClass') || '0'), act: Number(cell(table, row, 'Act') || '0'), sizeX: Number(cell(table, row, 'SizeX') || '0'), sizeY: Number(cell(table, row, 'SizeY') || '0'), xOffset: Number(cell(table, row, 'Xoffset') || '0'), yOffset: Number(cell(table, row, 'Yoffset') || '0'), isDoor: cell(table, row, 'IsDoor') !== '0' && cell(table, row, 'IsDoor') !== '', trans: Number(cell(table, row, 'Trans') || '0'), draw: Number(cell(table, row, 'Draw') || '0'), totalPieces: Number(cell(table, row, 'TotalPieces') || '0'), autoMap: Number(cell(table, row, 'AutoMap') || '0'), mode: numbers('Mode', OBJECT_MODE_COUNT), selectable: numbers('Selectable', OBJECT_MODE_COUNT), frameCnt: numbers('FrameCnt', OBJECT_MODE_COUNT), frameDelta: numbers('FrameDelta', OBJECT_MODE_COUNT), start: numbers('Start', OBJECT_MODE_COUNT), cycleAnim: numbers('CycleAnim', OBJECT_MODE_COUNT), lit: numbers('Lit', OBJECT_MODE_COUNT), components, } } /** * Load and parse `data\global\excel\objects.txt` from a mounted archive stack. * * Uses `parseTable`/`cell` from `src/game/acts.ts` rather than a second * tab-separated reader, so the two cannot disagree about CRLF, a trailing newline * or a missing column. * * @param archives - the mounted archives (the file lives in `d2data.mpq`). * @returns the parsed table and its id lookup. */ export async function loadObjectsTable(archives: MountedArchives): Promise { const table = parseTable(await archives.read(OBJECTS_TABLE)) const rows: ObjectsRow[] = [] for (const raw of table.rows) { const row = toObjectsRow(table, raw) if (!Number.isFinite(row.id)) continue rows.push(row) } const byId = new Map() for (const row of rows) byId.set(row.id, row) return { table, rows, byId } } export interface MonstersTable { /** Parsed monstats.txt. */ readonly stats: D2Table /** Parsed MonPreset.txt. */ readonly preset: D2Table /** Pre-filtered MonPreset Place column values, grouped by String(act). */ readonly presetPlaceByAct: ReadonlyMap /** Pre-indexed monstats.txt rows by Id (column 0). */ readonly statsById: ReadonlyMap } /** * Resolve a DS1 object entry to the art the engine would use for it. * * Two lookups happen here, in this order: * * 1. `(act, type, id)` goes through the hardcoded per-act object table * ({@link lookupObject}). The DS1 `id` is **not** an `Objects.txt` row number — * it indexes that table, which is why identity mapping produced nonsense * (an act 1 fountain, id 0, became `Objects.txt` row 0, "Expansion", token `''`). * The table's `token` is authoritative: in 26 places `Objects.txt` carries a * placeholder (`SS`/`XX`/`SL`) where the table has the real token. * 2. The table's `objectsTxtId`, or failing that the table's token, is used to find * the `Objects.txt` row, which supplies the metadata (name, size, modes, flags). * * @param tables - the parsed `Objects.txt`. * @param act - the level's act, 1..5. * @param objectType - the DS1 object's `type` (2 = object, 1 = monster spawn). * @param objectId - the DS1 object's `id`. * @param monsters - precomputed monster lookup tables. Omitting this parameter intentionally restores the legacy behavior of dropping all non-object (type !== 2) DS1 entries. * @returns the entry, the optional metadata row, and the token/mode to draw with. * @throws when the object type is an object but the table has no such id, because a * silent fallback would bake the wrong token into the pack under the right id. */ export function resolveDs1Object( tables: ObjectsTable, act: number, objectType: number, objectId: number, monsters?: MonstersTable, ): ResolvedDs1Object { if (!Number.isFinite(objectId)) { throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`) } if (objectType !== OBJECT_TYPE_OBJECT) { if (objectType === OBJECT_TYPE_MONSTER && monsters !== undefined) { const presets = monsters.presetPlaceByAct.get(String(act)) if (presets !== undefined && objectId >= 0 && objectId < presets.length) { const place = presets[objectId]! const statRow = monsters.statsById.get(place) // `Code` is spelled inconsistently in the shipped table — `K9` on one row, // `k9` on another, `ja` lower-cased — while the art directories are indexed // upper-cased. Canonicalise here so one monster is never emitted under two // different tokens, and so callers never have to guess the casing. const token = statRow === undefined ? '' : cell(monsters.stats, statRow, 'Code').trim().toUpperCase() const nameStr = statRow === undefined ? '' : cell(monsters.stats, statRow, 'NameStr') return { entry: null, row: null, token, mode: 'NU', artless: token === '', kind: 'npc', name: nameStr || place, } } } return { entry: null, row: null, token: '', mode: '', artless: true, kind: 'monster' } } const entry = lookupObject(act, objectType, objectId) if (entry === null) { throw new Error(`resolveDs1Object: act ${String(act)} object id ${String(objectId)} is not in the object lookup table`) } const byId = entry.objectsTxtId >= 0 ? tables.byId.get(entry.objectsTxtId) : undefined const row = byId ?? (entry.token === '' ? undefined : findByToken(tables, entry.token)) ?? null const token = entry.token !== '' ? entry.token : (row?.token ?? '') return { entry, row, token, mode: entry.mode, artless: token.trim() === '', kind: entry.baseIsMonsters ? 'npc' : 'object', } } /** * Find an `Objects.txt` row by token (case-insensitive), for records whose lookup * entry has no row number. * * @param tables - the parsed table. * @param token - the token to look for. * @returns the first matching row, or undefined. */ function findByToken(tables: ObjectsTable, token: string): ObjectsRow | undefined { return tables.rows.find(row => row.token.trim().toLowerCase() === token.trim().toLowerCase()) } /** What {@link resolveDs1Object} found for one DS1 object entry. */ export interface ResolvedDs1Object { /** The hardcoded lookup table record, or null for a monster spawn. */ readonly entry: ObjectLookupEntry | null /** The `Objects.txt` row, when one could be found (by id, else by token). */ readonly row: ObjectsRow | null /** The token to draw with; empty when the object has no art. */ readonly token: string /** The animation mode token the engine places it in (`NU`/`OP`/…); empty when unknown. */ readonly mode: string /** True when no token could be found: drawing nothing is then correct. */ readonly artless: boolean /** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named NPC. */ readonly kind: 'object' | 'monster' | 'npc' /** The name resolved for this object (like the name of the NPC), if any. */ readonly name?: string } /** What {@link resolveObjectArt} is asked to resolve. */ export interface ObjectArtRequest { /** The DS1 object, exactly as decoded; `x`/`y` are sub-tiles. */ readonly object: { type: number; id: number; x: number; y: number; flags: number } /** * The token to draw with, taken from the hardcoded object lookup table (it wins * over `Objects.txt`'s `Token` column, which carries placeholders in 26 places). */ readonly token: string /** * The animation mode token the engine places the object in, from the same table * (`NU`/`OP`/`ON`/`S1`…); empty string when the table did not say. */ readonly mode: string /** * The `Objects.txt` row, or `null` when the table gave no row and the token did * not match one. Only metadata (name, sub-class) is read from it. */ readonly row: { name: string; token: string; subClass: number; mode: number; hp: number } | null /** Every member name in the mounted archives, for name-only resolution. */ readonly members: readonly string[] /** True when the object is effectively an NPC (`baseIsMonsters`), resolving in `monsters/` not `objects/`. */ readonly baseIsMonsters?: boolean } /** The art the engine would use for one DS1 object. */ export interface ObjectArt { /** The archive member, or `null` when the token ships no art at all. */ readonly member: string | null /** The animation-mode token actually used, e.g. `NU`. */ readonly mode: string /** Frame index within the resolved member; 0 is the mode's first frame here. */ readonly frameIndex: number /** Client pixel draw position of the object, frame offsets excluded. */ readonly anchor: { readonly x: number; readonly y: number } /** Everything the resolution could not decide, or decided by fallback. */ readonly notes: readonly string[] } /** * The COF member name the engine builds for an object token and mode. * * The literal template from `D2Common_10884_COMPOSIT_unk`: * `DATA\GLOBAL\OBJECTS\\COF\hth.COF`, with the space-stripping * already applied (the tokens are trimmed, so there is nothing left to strip). * * @param token - `Objects.txt` `Token`, any case. * @param modeIndex - animation mode index, 0..7. * @returns the lower-cased member name. */ export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string { const name = token.trim().toLowerCase() const mode = modeToken(modeIndex).toLowerCase() const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT return `${root}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof` } /** * The sprite member name for one component of an object's composition. * * Reconstructed the way `verify-dcc.ts` and `src/game/character.ts` reconstruct a * character's: the COF stores no file name, only the component index and the * weapon class, so the name is `lithth.dcc` in the * component's directory. Objects keep the armor class fixed at `lit`. * * @param token - `Objects.txt` `Token`, any case. * @param component - component directory, e.g. `tr`. * @param modeIndex - animation mode index, 0..7. * @param baseIsMonsters - true when the base directory is `monsters/` not `objects/`. * @returns the lower-cased `.dcc` member name. */ export function objectSpriteMember(token: string, component: string, modeIndex: number, baseIsMonsters = false): string { const name = token.trim().toLowerCase() const part = component.toLowerCase() const mode = modeToken(modeIndex).toLowerCase() const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT return `${root}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc` } /** * The mode token for an index, clamped into range. * * @param modeIndex - animation mode index. * @returns the token, e.g. `NU`. */ function modeToken(modeIndex: number): string { const index = Number.isFinite(modeIndex) ? Math.trunc(modeIndex) : 0 return OBJECT_MODE_TOKENS[Math.min(Math.max(index, 0), OBJECT_MODE_TOKENS.length - 1)] ?? 'NU' } /** * The engine's member order, with the requested mode first. * * A placed object is in `NU`, so `NU` is first for every object the packer sees; * the requested mode comes first only so a caller that knows better (an opened * door, an operating chest) is not overridden. * * @param requested - the requested animation mode index. * @returns mode indices, most preferred first. */ function modeOrder(requested: number): number[] { const start = Number.isFinite(requested) ? Math.min(Math.max(Math.trunc(requested), 0), OBJECT_MODE_COUNT - 1) : 0 const order = [start] for (let index = 0; index < OBJECT_MODE_COUNT; index += 1) if (index !== start) order.push(index) return order } /** * Find a member case-insensitively, preferring the archive's own spelling. * * The engine's lookups go through Storm's case-insensitive hash, and the archives * really do mix case (`Data\Global\Objects\1Y\S1\1ys1litnuhth.DC6`, * `data\global\objects\C5\COF\C5NUHTH.COF`), so a byte-exact compare would fail. * * @param members - every member name. * @param wanted - the lower-cased name to find. * @returns the member as the archive spells it, or undefined. */ function findMember(members: readonly string[], wanted: string): string | undefined { const exact = members.find(name => name.toLowerCase() === wanted) return exact } /** * Resolve the art member an object is drawn from, from a member-name list. * * The engine's rule, in order: the **mode token** comes from the object's * animation mode through `objmode.txt` * (`DATATBLS_GetObjModeTypeTxtRecord(nMode, 1)`), the **class token** from * `Objects.txt`/`objtype.txt`, and the path is the fixed template in * `D2Common_10884_COMPOSIT_unk`. What this function adds over that is only the * fallback for a mode the object has no art for, and the choice of *component* * when the COF cannot be read: the COF is the engine's real entry point, and it * declares which components exist. With names alone, `tr` (the body layer, first * layer of 1453 of 1461 shipped object COFs) is preferred, then the composite * order. * * Two things this cannot decide, reported in `notes` rather than guessed: * a mode the object has no art for, and the frame index inside that mode * (`Objects.txt` `Start{mode}` gives the mode's first frame within the COF, and * `FrameCnt{mode}` its length, so frame 0 here is the COF's first frame — the * mode's own first frame only when `Start{mode}` is 0). * * @param request - the object, its row (or null) and the archive's member list. * @returns the member, the mode token, the frame index, the anchor and notes. * @throws when the request is malformed — a missing token or a non-finite object * position means the caller lost data, and a wrong member is worse than a stop. * A token that genuinely ships no art returns `member: null` instead, because * that is what the engine draws: nothing. */ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt { const { object, members } = request if (!Number.isFinite(object.type) || !Number.isFinite(object.id) || !Number.isFinite(object.x) || !Number.isFinite(object.y) || !Number.isFinite(object.flags)) { throw new Error(`resolveObjectArt: object ${String(object.type)}/${String(object.id)} at (${String(object.x)}, ${String(object.y)}) is not finite`) } const notes: string[] = [] const anchor = objectAnchor(object) const token = request.token.trim().toUpperCase() if (token === '') { notes.push(`act object id ${String(object.id)} has no token in the lookup table, so it has no art`) return { member: null, mode: 'NU', frameIndex: 0, anchor, notes } } // The mode comes from the lookup table (`OP` for a casket, `ON` for a campfire); // when it does not say, the engine's placement default is `NU`. const requestedToken = request.mode.trim().toUpperCase() const requestedIndex = (OBJECT_MODE_TOKENS as readonly string[]).indexOf(requestedToken) const requested = requestedIndex >= 0 ? requestedIndex : 0 if (requestedToken !== '' && requestedIndex < 0) { notes.push(`lookup table mode "${requestedToken}" is not one of ${OBJECT_MODE_TOKENS.join('/')}; using NU`) } if (requestedIndex > 0) notes.push(`placed in mode ${requestedToken} by the lookup table`) // The engine loads a COF, and the COF lists the layers. Prefer the mode the // object is in; fall back through the mode order when this token ships no art // for it (a chest in a level that placed it opened, for instance). let modeIndex = requested let cof: string | undefined for (const candidate of modeOrder(requested)) { const found = findMember(members, objectCofMember(token, candidate, request.baseIsMonsters)) if (found !== undefined) { cof = found; modeIndex = candidate; break } } if (cof === undefined) { notes.push(`token ${token} ships no COF for any mode (looked for ${objectCofMember(token, requested, request.baseIsMonsters)})`) } else if (modeIndex !== requested) { notes.push(`mode ${String(requested)} has no COF; fell back to ${OBJECT_MODE_TOKENS[modeIndex] ?? 'NU'}`) } // Sprites: the same tokens as the COF name, one file per component directory. let member: string | null = null let chosenMode = modeIndex const layers: string[] = [] const order = cof === undefined ? modeOrder(requested) : [modeIndex] outer: for (const index of order) { for (const component of COMPONENT_PREFERENCE) { const found = findMember(members, objectSpriteMember(token, component, index, request.baseIsMonsters)) if (found === undefined) continue layers.push(found) if (member === null) { member = found; chosenMode = index } } if (member !== null) break outer } // `lit` is not universal in the shipped set: a handful of tokens store the same // name without it, so those are matched too rather than reported as missing. if (member === null) { for (const index of order) { const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu' const root = request.baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT const prefix = `${root}${token.toLowerCase()}\\` const suffix = `${mode}${OBJECT_WEAPON}.dcc` const hits = members .filter(name => name.toLowerCase().startsWith(prefix) && name.toLowerCase().endsWith(suffix)) .sort() if (hits.length > 0) { member = hits[0] ?? null; chosenMode = index; layers.push(...hits); break } } } if (member === null) { notes.push(`token ${token} ships no sprite member for mode ${OBJECT_MODE_TOKENS[chosenMode] ?? 'NU'}`) } if (cof !== undefined) notes.push(`cof ${cof}`) if (layers.length > 1) notes.push(`layers ${layers.join(' ')}`) if (request.row !== null && (request.row.mode < 0 || request.row.mode >= OBJECT_MODE_COUNT)) { notes.push(`row mode ${String(request.row.mode)} is outside 0..${String(OBJECT_MODE_COUNT - 1)}`) } notes.push('frameIndex is the COF\'s first frame; add Objects.txt Start{mode} for the mode\'s own first frame') return { member, mode: OBJECT_MODE_TOKENS[chosenMode] ?? 'NU', frameIndex: 0, anchor, notes, } } /** One composited object animation, the way the engine builds one. */ export interface ObjectSheet { /** The composited frames for direction 0, in COF frame order. */ readonly sheet: SpriteSheet /** Directions the COF declares (1 for most objects, 4 for a rare few). */ readonly directions: number /** Frames per direction, i.e. the COF's own count. */ readonly framesPerDirection: number /** The COF member that was read. */ readonly cof: string /** The sprite members the COF's layers resolved to, in draw order. */ readonly members: readonly string[] /** Layers with no sprite, or that failed to decode. */ readonly skipped: number /** Non-fatal observations. */ readonly notes: readonly string[] } /** * Build an object's frames the way the engine does: read the COF, then its layers. * * This is the authoritative path — it is what `D2Common_10884_COMPOSIT_unk` * resolves and what `D2Comp.cpp`'s `sub_6F8A1250` loads — and it needs the * archives, because the component list lives inside the COF bytes and nowhere * else. The layer decoders are `src/formats/cof.ts` and `src/formats/dcc.ts`, the * same ones `src/game/character.ts` uses; the draw order comes from * `cofLayerOrder`, and the frame-window arithmetic is `Start{mode}` + * `FrameCnt{mode}` from `Objects.txt` when the row is supplied. * * @param archives - the mounted archives. * @param token - `Objects.txt` `Token`. * @param modeIndex - animation mode index, 0..7. * @param row - the `Objects.txt` row, for the mode's frame window; optional. * @returns the composited sheet. * @throws when the COF for that token and mode is absent, naming both. */ export async function loadObjectSheet( archives: MountedArchives, token: string, modeIndex: number, row?: ObjectsRow, baseIsMonsters = false, ): Promise { const cofMember = objectCofMember(token, modeIndex, baseIsMonsters) let cof: CofFile try { cof = decodeCof(await archives.read(cofMember)) } catch (err) { throw new Error(`loadObjectSheet: ${cofMember} (token ${token}, mode ${OBJECT_MODE_TOKENS[modeIndex] ?? '?'}) failed: ${(err as Error).message}`) } const names = await archives.listFiles() const notes: string[] = [] const members: string[] = [] const sprites: (DccFile | null)[] = [] for (const layer of cof.layers) { const component = OBJECT_COMPONENTS[layer.type] if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`); sprites.push(null); continue } const wanted = objectSpriteMember(token, component, modeIndex, baseIsMonsters) let found = findMember(names, wanted) if (found === undefined) found = findMember(names, wanted.replace(/\.dcc$/i, '.dc6')) if (found === undefined) { notes.push(`no sprite for component ${component} (${wanted})`); sprites.push(null); continue } try { sprites.push(decodeDcc(await archives.read(found))) members.push(found) } catch (err) { notes.push(`${found}: ${(err as Error).message}`) sprites.push(null) } } const start = row === undefined ? 0 : (row.start[modeIndex] ?? 0) const count = row === undefined ? cof.framesPerDirection : Math.max(1, row.frameCnt[modeIndex] ?? 1) const frames: SpriteFrame[] = [] for (let index = 0; index < cof.framesPerDirection; index += 1) frames.push(blankFrame()) const wanted = row === undefined ? [0] : Array.from({ length: count }, (_, offset) => start + offset) const directions = Math.max(1, cof.numberOfDirections) for (const index of wanted) { if (index < 0 || index >= cof.framesPerDirection) continue let left = Number.POSITIVE_INFINITY let top = Number.POSITIVE_INFINITY let right = Number.NEGATIVE_INFINITY let bottom = Number.NEGATIVE_INFINITY let placed = 0 for (const sprite of sprites) { if (sprite === null) continue const direction = sprite.directions[0] const frame = direction?.frames[index] if (frame === undefined) continue left = Math.min(left, direction!.box.left) top = Math.min(top, direction!.box.top) right = Math.max(right, direction!.box.left + direction!.box.width) bottom = Math.max(bottom, direction!.box.top + direction!.box.height) placed += 1 } if (placed === 0) continue const width = Math.max(1, Math.round(right) - Math.round(left)) const height = Math.max(1, Math.round(bottom) - Math.round(top)) const composed: SpriteFrame = { width, height, indices: new Uint8Array(width * height), mask: new Uint8Array(width * height) } const ordered = cofLayerOrder(cof, 0, index) for (const layerIndex of ordered) { const sprite = sprites[layerIndex] if (sprite === null || sprite === undefined) continue const direction = sprite.directions[0] const frame = direction?.frames[index] if (frame === undefined) continue blit(composed, frame.frame, Math.round(direction!.box.left) - Math.round(left), Math.round(direction!.box.top) - Math.round(top)) } frames[index] = composed } return { sheet: { groups: [{ frames }], width: null }, directions, framesPerDirection: cof.framesPerDirection, cof: cofMember, members, skipped: sprites.filter(sprite => sprite === null).length, notes, } } /** * A transparent 1×1 frame, so a slot with no art still occupies its COF index. * * @returns the frame. */ function blankFrame(): SpriteFrame { return { width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1) } } /** * Masked blit of one layer onto the composed frame. * * Palette index 0 is transparent in D2's art, so a layer paints only where it has * a non-zero pixel — the same rule the DT1 tile compositor uses. * * @param target - destination frame buffers. * @param source - the layer's frame. * @param atX - destination x. * @param atY - destination y. */ function blit( target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number }, source: SpriteFrame, atX: number, atY: number, ): void { for (let y = 0; y < source.height; y += 1) { const ty = atY + y if (ty < 0 || ty >= target.height) continue for (let x = 0; x < source.width; x += 1) { const value = source.indices[y * source.width + x] ?? 0 if (value === 0) continue const tx = atX + x if (tx < 0 || tx >= target.width) continue target.indices[ty * target.width + tx] = value target.mask[ty * target.width + tx] = 1 } } }