fix(drlg): resolve M1 iteration 2 audit findings (animSpeed, crossActObjOf, strip-types, __d2Scene, D2Rng.int)

This commit is contained in:
troytt 2026-09-29 10:29:42 +00:00
parent 8ddfbd318a
commit c2c1d280d5
26 changed files with 174 additions and 87 deletions

View File

@ -1476,7 +1476,7 @@ async function bakeDs1Variant(
} : {}),
})
if (pick !== null) objectsWithArt += 1
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(object.act ?? entry.act)}) has no art members`)
else if (!resolved.artless) missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(object.act ?? entry.act)}) has no art members`)
}
const objectFiles = objectPages.encode(0)

View File

@ -240,12 +240,22 @@ export interface MonsterResult {
export type EntityResult = CharResult | MonsterResult
class EntityWorkerContext {
public readonly archives: MountedArchives
public readonly animData: any
public readonly monstats2: any
public readonly palettes: Record<number, Uint8Array>
constructor(
public readonly archives: MountedArchives,
public readonly animData: any,
public readonly monstats2: any,
public readonly palettes: Record<number, Uint8Array>,
) {}
archives: MountedArchives,
animData: any,
monstats2: any,
palettes: Record<number, Uint8Array>,
) {
this.archives = archives
this.animData = animData
this.monstats2 = monstats2
this.palettes = palettes
}
static async create(archiveDir: string, outDir: string): Promise<EntityWorkerContext> {
const archives = new MountedArchives()

View File

@ -1,5 +1,5 @@
import { decompressSector, COMPRESSION_ADPCM_MONO } from '../src/mpq/decompress.js';
import { AudioManager } from '../src/audio/manager.js';
import { decompressSector, COMPRESSION_ADPCM_MONO } from '../src/mpq/decompress.ts';
import { AudioManager } from '../src/audio/manager.ts';
async function run() {
console.log("Verifying Audio dependencies...");

View File

@ -78,7 +78,7 @@
* comparing one file first-wins against the other last-wins. Comparing like for
* like, the difference count is 0.)
*/
import { ByteReader, InvalidFieldError } from './reader'
import { ByteReader, InvalidFieldError } from './reader.ts'
/** Hash blocks in the file, one per possible value of the name hash. */
export const ANIMDATA_BLOCK_COUNT = 256

View File

@ -1,4 +1,4 @@
import { InvalidFieldError, TruncatedDataError } from "./reader";
import { InvalidFieldError, TruncatedDataError } from "./reader.ts";
/**
* Least-significant-bit-first bit reader for Diablo II's packed sprite

View File

@ -54,7 +54,7 @@ const DEFAULT_MAX_FRAMES = 20000
/** Default guard for one frame (a 4096×4096 indexed frame). */
const DEFAULT_MAX_FRAME_PIXELS = 1 << 24
import { requireBytes } from './reader'
import { requireBytes } from './reader.ts'
/** Read a little-endian uint32. */
function u32(data: Uint8Array, at: number): number {

View File

@ -25,7 +25,7 @@
* is exactly why a renderer must resolve them through the layer records instead
* of using them directly. That resolution is `cofLayerOrder`.
*/
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader'
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader.ts'
/** Fixed part of the file, up to and including the animation-speed byte. */
const HEADER_SIZE = 25

View File

@ -19,7 +19,7 @@
* entry unused), which is why frames carry a mask rather than relying on a
* sentinel index.
*/
import { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader'
import { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader.ts'
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
/** Byte offset of the file header, and its size. */

View File

@ -1,4 +1,4 @@
import { CelError } from "./sprite";
import { CelError } from "./sprite.ts";
/**
* Diablo II `.ds1` map decoder.
@ -15,7 +15,7 @@ import { CelError } from "./sprite";
* versions below 4. Both are encoded here exactly as the reference decoder
* reads them, including the pre-7 orientation lookup table.
*/
import { FormatError, InvalidFieldError, TruncatedDataError } from './reader'
import { FormatError, InvalidFieldError, TruncatedDataError } from './reader.ts'
/** Pre-7 maps store orientations through this lookup rather than directly. */
const LEGACY_DIRECTION_LOOKUP = [

View File

@ -1,6 +1,6 @@
import { CelError } from "./sprite";
import { CelError } from "./sprite.ts";
import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader'
import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader.ts'
/** Bytes of the fixed header before the tile count. */
const HEADER_PREFIX = 8

View File

@ -1,4 +1,4 @@
import { FormatError } from "./reader";
import { FormatError } from "./reader.ts";
/**

View File

@ -7,7 +7,7 @@
* the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples).
*/
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader'
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader.ts'
/** One decoded image, ready to blit. */
export interface PcxImage {

View File

@ -1,4 +1,4 @@
import { ByteReader, FormatError, TruncatedDataError } from "./reader";
import { ByteReader, FormatError, TruncatedDataError } from "./reader.ts";
/**
* Diablo II `.pl2` palette+transform decoder.

View File

@ -1,4 +1,4 @@
import { requireBytes } from './reader'
import { requireBytes } from './reader.ts'
/**
* Diablo II `.tbl` string-table decoder (hash-bucket layout).
*

View File

@ -79,11 +79,15 @@ function normalizeMember(path: string): string {
*/
export class WorldVariants<E extends VariantEntry> {
private readonly pinned = new Map<number, number>()
private readonly index: VariantIndex<E>
private readonly seed: number
constructor(
private readonly index: VariantIndex<E>,
private readonly seed: number = WORLD_VARIANT_SEED,
index: VariantIndex<E>,
seed: number = WORLD_VARIANT_SEED,
) {
this.index = index
this.seed = seed
for (const [act, layouts] of Object.entries(index.actLayouts ?? {})) {
if (layouts.length === 0) throw new Error(`actLayouts[${act}] is empty`)
layouts.forEach((layout, at) => {

View File

@ -175,8 +175,9 @@ export class D2Rng {
* Draw an integer in an inclusive range `[min, max]`.
*/
int(min: number, max: number): number {
const lo = Math.ceil(Math.min(min, max))
const hi = Math.floor(Math.max(min, max))
if (max <= min) return Math.floor(min)
const lo = Math.ceil(min)
const hi = Math.floor(max)
const span = hi - lo + 1
if (span <= 0) return lo
return lo + this.rand(span)

View File

@ -529,6 +529,15 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
objSlotOf.set(DRLGPRESET_GetObjectIndexFromObjPreset(nAct, slot), slot) // lowest slot wins
}
const crossActObjOf = new Map<number, { readonly act: number; readonly slot: number }>()
for (let a = 4; a >= 0; a -= 1) {
for (let slot = DS1_OBJPRESET_SLOTS - 1; slot >= 0; slot -= 1) {
const objIdx = DRLGPRESET_GetObjectIndexFromObjPreset(a, slot)
if (objIdx >= 0 && objIdx !== OBJECT_CLASS_INVALID_PRESET) {
crossActObjOf.set(objIdx, { act: a + 1, slot })
}
}
}
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
@ -574,7 +583,15 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
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 })
const crossObj = slot === undefined ? crossActObjOf.get(u.index) : undefined
objects.push({
type: DS1_TYPE_OBJECT,
id: slot ?? crossObj?.slot ?? u.index + DS1_OBJPRESET_SLOTS,
x: u.subX,
y: u.subY,
flags: u.spawned,
...(crossObj !== undefined ? { act: crossObj.act } : {}),
})
nObjects += 1
// Throne of Destruction (131, wthrone.ds1) connects to The Worldstone Chamber (132, Vis1=132,
// Warp1=82) via the preset object BaalsPortal (Objects.txt row 563, "The Worldstone Chamber")
@ -961,8 +978,9 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables:
for (const room of level.rooms) {
if (room.preset) {
const prest = DATATBLS_GetLvlPrestTxtRecord(tables, room.preset.prest)
if (prest.dwAnimate !== 0 && prest.nAnimSpeed > 0) {
animSpeed = prest.nAnimSpeed
const speed = prest.nAnimSpeed > 0 ? prest.nAnimSpeed : prest.dwAnimate
if (speed > 0) {
animSpeed = speed
break
}
}

View File

@ -170,10 +170,14 @@ export function DRLGPRESET_MapTileType(nId: number): number {
/** `int32_t* pData` walking over a DS1 image (unaligned little-endian reads, bounds-checked). */
class Ds1Cursor {
private readonly bytes: Uint8Array
private readonly path: string
private readonly view: DataView
offset: number
constructor(private readonly bytes: Uint8Array, offset: number, private readonly path: string) {
constructor(bytes: Uint8Array, offset: number, path: string) {
this.bytes = bytes
this.path = path
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
this.offset = offset
}

View File

@ -186,41 +186,44 @@ export interface ItemFlagsDecoded {
}
/** Location / storage mode of the item in D2 bitstream (3 bits). */
export enum ItemMode {
STORED = 0, // In inventory, stash, or Horadric Cube
EQUIPPED = 1, // Equipped on character body
BELT = 2, // Slotted in potion belt
GROUND = 3, // Dropped on the ground
CURSOR = 4, // Held on mouse cursor
DROPPING = 5, // Currently in drop motion
SOCKETED = 6, // Socketed into a parent item
}
export type ItemMode = 0 | 1 | 2 | 3 | 4 | 5 | 6
export const ItemMode = {
STORED: 0 as ItemMode, // In inventory, stash, or Horadric Cube
EQUIPPED: 1 as ItemMode, // Equipped on character body
BELT: 2 as ItemMode, // Slotted in potion belt
GROUND: 3 as ItemMode, // Dropped on the ground
CURSOR: 4 as ItemMode, // Held on mouse cursor
DROPPING: 5 as ItemMode, // Currently in drop motion
SOCKETED: 6 as ItemMode, // Socketed into a parent item
} as const
/** Body equipment slot (4 bits). */
export enum EquippedSlot {
NONE = 0,
HEAD = 1,
NECK = 2,
TORSO = 3,
RIGHT_HAND = 4,
LEFT_HAND = 5,
RIGHT_FINGER = 6,
LEFT_FINGER = 7,
WAIST = 8,
FEET = 9,
HANDS = 10,
ALT_RIGHT_HAND = 11,
ALT_LEFT_HAND = 12,
}
export type EquippedSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
export const EquippedSlot = {
NONE: 0 as EquippedSlot,
HEAD: 1 as EquippedSlot,
NECK: 2 as EquippedSlot,
TORSO: 3 as EquippedSlot,
RIGHT_HAND: 4 as EquippedSlot,
LEFT_HAND: 5 as EquippedSlot,
RIGHT_FINGER: 6 as EquippedSlot,
LEFT_FINGER: 7 as EquippedSlot,
WAIST: 8 as EquippedSlot,
FEET: 9 as EquippedSlot,
HANDS: 10 as EquippedSlot,
ALT_RIGHT_HAND: 11 as EquippedSlot,
ALT_LEFT_HAND: 12 as EquippedSlot,
} as const
/** Container / storage page (3 bits). */
export enum StoragePage {
INVENTORY = 0,
EQUIPPED = 1,
BELT = 2,
CUBE = 4,
STASH = 5,
}
export type StoragePage = 0 | 1 | 2 | 4 | 5
export const StoragePage = {
INVENTORY: 0 as StoragePage,
EQUIPPED: 1 as StoragePage,
BELT: 2 as StoragePage,
CUBE: 4 as StoragePage,
STASH: 5 as StoragePage,
} as const
/** Item location and positioning structure. */
export interface ItemLocation {
@ -241,17 +244,18 @@ export interface ItemLocation {
}
/** Quality tier of the item (4 bits). */
export enum ItemQuality {
LOW = 1, // Inferior / Low Quality / Cracked / Crude / Damaged
NORMAL = 2, // Normal base
SUPERIOR = 3, // Superior / High Quality
MAGIC = 4, // Magic
SET = 5, // Set
RARE = 6, // Rare
UNIQUE = 7, // Unique
CRAFTED = 8, // Crafted
TEMPERED = 9, // Tempered (expansion prototype remnant)
}
export type ItemQuality = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
export const ItemQuality = {
LOW: 1 as ItemQuality, // Inferior / Low Quality / Cracked / Crude / Damaged
NORMAL: 2 as ItemQuality, // Normal base
SUPERIOR: 3 as ItemQuality, // Superior / High Quality
MAGIC: 4 as ItemQuality, // Magic
SET: 5 as ItemQuality, // Set
RARE: 6 as ItemQuality, // Rare
UNIQUE: 7 as ItemQuality, // Unique
CRAFTED: 8 as ItemQuality, // Crafted
TEMPERED: 9 as ItemQuality, // Tempered (expansion prototype remnant)
} as const
/** One affix slot for Rare / Crafted items (1 bit present + 11-bit id). */
export interface ItemAffixSlot {

View File

@ -195,14 +195,28 @@ export interface TreasureClassTable {
* Concrete implementation of `TreasureClassTable`.
*/
class TreasureClassTableImpl implements TreasureClassTable {
readonly all: TreasureClassNode[]
readonly byName: Map<string, TreasureClassNode>
readonly byGroup: Map<number, TreasureClassNode[]>
readonly danglingReferences: ReadonlySet<string>
readonly autoTCDanglingReferences: ReadonlySet<string>
readonly baseItemDanglingReferences: ReadonlySet<string>
constructor(
readonly all: TreasureClassNode[],
readonly byName: Map<string, TreasureClassNode>,
readonly byGroup: Map<number, TreasureClassNode[]>,
readonly danglingReferences: ReadonlySet<string>,
readonly autoTCDanglingReferences: ReadonlySet<string>,
readonly baseItemDanglingReferences: ReadonlySet<string>,
) {}
all: TreasureClassNode[],
byName: Map<string, TreasureClassNode>,
byGroup: Map<number, TreasureClassNode[]>,
danglingReferences: ReadonlySet<string>,
autoTCDanglingReferences: ReadonlySet<string>,
baseItemDanglingReferences: ReadonlySet<string>,
) {
this.all = all
this.byName = byName
this.byGroup = byGroup
this.danglingReferences = danglingReferences
this.autoTCDanglingReferences = autoTCDanglingReferences
this.baseItemDanglingReferences = baseItemDanglingReferences
}
get(name: string): TreasureClassNode | undefined {
return this.byName.get(name)

View File

@ -1,4 +1,4 @@
import { NEXT_STEP_TABLE, STEP_SIZE_TABLE } from './adpcm-tables.js';
import { NEXT_STEP_TABLE, STEP_SIZE_TABLE } from './adpcm-tables.ts';
export class AdpcmError extends Error {
constructor(message: string) {

View File

@ -12,9 +12,9 @@
* `implode.ts` and is called here.
*/
import { ImplodeError, explode } from './implode.js';
import { decompressHuffman } from './huffman.js';
import { decompressAdpcm } from './adpcm.js';
import { ImplodeError, explode } from './implode.ts';
import { decompressHuffman } from './huffman.ts';
import { decompressAdpcm } from './adpcm.ts';
/** Codec bit: adaptive Huffmann (FGK variant). */
export const COMPRESSION_HUFFMANN = 0x01

View File

@ -4,7 +4,7 @@ export class HuffmanError extends Error {
this.name = 'HuffmanError';
}
}
import { HUFFMAN_WEIGHT_TABLES } from './huffman-tables.js';
import { HUFFMAN_WEIGHT_TABLES } from './huffman-tables.ts';
export const HUFF_DECOMPRESS_ERROR = 0x1FF;
@ -74,10 +74,11 @@ class InputStream {
}
}
enum InsertPoint {
InsertAfter = 1,
InsertBefore = 2
}
const InsertPoint = {
InsertAfter: 1,
InsertBefore: 2,
} as const;
type InsertPoint = (typeof InsertPoint)[keyof typeof InsertPoint];
class THuffmanTree {
listHead: THuffmanTreeItem;

View File

@ -250,6 +250,7 @@ export const state: ActSceneState = {
if (typeof window !== 'undefined') {
window.__d2webAct = state
window.__d2webActs = state
;(window as unknown as { __d2Scene?: ActSceneState }).__d2Scene = state
}
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {

View File

@ -1,3 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, test } from 'vitest'
import {
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
@ -143,4 +145,25 @@ describe('Animated Tile Speed from LvlPrest.txt Animate Column (Issue #85)', ()
expect(tile.currentFrameIndex).toBe(2)
})
})
describe('Baked Act 4 Lava and Act 5 Hell portal scenes preserve animSpeed = 1 (10240ms)', () => {
const lavaScenes = [
'samples/d2-packs/act4/107-act-4-lava-1-var1/scene.json',
'samples/d2-packs/act4/108-act-4-diablo-1-var1/scene.json',
'samples/d2-packs/act5/125-act-5-hell-1-var1/scene.json',
'samples/d2-packs/act5/126-act-5-hell-2-var1/scene.json',
'samples/d2-packs/act5/127-act-5-hell-3-var1/scene.json',
]
test.each(lavaScenes)('%s has animSpeed === 1 and frameDurationMs === 10240', relPath => {
const fullPath = resolve(process.cwd(), relPath)
expect(existsSync(fullPath)).toBe(true)
const scene = JSON.parse(readFileSync(fullPath, 'utf8')) as {
animSpeed?: number
frameDurationMs?: number
}
expect(scene.animSpeed).toBe(1)
expect(scene.frameDurationMs).toBe(10240)
})
})
})

View File

@ -310,7 +310,14 @@ describe("1.13c Diablo II PRNG and Per-Unit Seed (D2Common!6FD510B0 / D2Game!6FC
expect(v).toBeGreaterThanOrEqual(5)
expect(v).toBeLessThanOrEqual(10)
}
const loBeforeDegenerate = rng.lo
const hiBeforeDegenerate = rng.hi
expect(rng.int(7, 7)).toBe(7)
expect(rng.lo).toBe(loBeforeDegenerate)
expect(rng.hi).toBe(hiBeforeDegenerate)
expect(rng.int(10, 5)).toBe(10)
expect(rng.lo).toBe(loBeforeDegenerate)
expect(rng.hi).toBe(hiBeforeDegenerate)
const r = rng.range(10, 20)
expect(r).toBeGreaterThanOrEqual(10)