728 lines
27 KiB
TypeScript
728 lines
27 KiB
TypeScript
import { describe, test, expect as vitestExpect } from 'vitest'
|
|
import * as fs from 'fs'
|
|
/**
|
|
* Verify the `.cof` and `.dcc` decoders against the real Diablo II archives.
|
|
*
|
|
* The Sorceress is the interesting case because her art is COF+DCC only: a COF
|
|
* names no sprite files, so the run has to reconstruct the DCC path from the
|
|
* COF's own name plus each layer record's composite type, then decode every
|
|
* layer of every direction and frame and confirm it produced opaque pixels. A
|
|
* decoder that silently returned zeroed frames would pass a "does it throw?"
|
|
* test and fail this one.
|
|
*
|
|
* Object art (barrels, chests, urns, doors) goes through the same path from the
|
|
* data archive, so a regression that only affects non-character art or
|
|
* single-direction animations is caught too.
|
|
*
|
|
* After the gated checks the script sweeps the whole Sorceress DCC set. That
|
|
* sweep is what puts a real number on the `bottom-up` frame flag: the decoder
|
|
* flips those frames vertically, the reference implementation panics on them
|
|
* instead, and nothing else in this project can tell how often that judgement
|
|
* call is exercised. Sweep failures are reported with their exact reason but do
|
|
* not fail the run, because the run's exit code is defined by the walk/stand
|
|
* members the renderer depends on.
|
|
*
|
|
* Nothing here grades its own homework: every assertion is about bytes read out
|
|
* of a Blizzard archive the user supplied.
|
|
*
|
|
* Usage:
|
|
* node scripts/verify-dcc.ts [directory] [--quick]
|
|
*
|
|
* Exits non-zero when a Sorceress walk/stand member — or an object member —
|
|
* fails to decode.
|
|
*/
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { cofLayerOrder, decodeCof } from '../src/formats/cof.ts'
|
|
import type { CofFile } from '../src/formats/cof.ts'
|
|
import { decodeDcc } from '../src/formats/dcc.ts'
|
|
import type { DccFile } from '../src/formats/dcc.ts'
|
|
import { decodePal } from '../src/formats/pal.ts'
|
|
import type { Palette } from '../src/formats/pal.ts'
|
|
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
|
const isSkip = true && !fs.existsSync('samples/d2');
|
|
const _results: any[] = [];
|
|
let suiteCompleted = false;
|
|
let problems: string[] = [];
|
|
let checks: any[] = [];
|
|
function expect(condition: boolean, description: string) { _results.push({cond: condition, desc: description}); if (!condition) problems.push(description); }
|
|
function check(nameOrOk: any, okOrMessage: any, detail?: any) {
|
|
if (typeof nameOrOk === 'string') {
|
|
_results.push({cond: okOrMessage, desc: nameOrOk, detail}); if (!okOrMessage) checks.push({name: nameOrOk, ok: okOrMessage});
|
|
} else {
|
|
_results.push({cond: nameOrOk, desc: okOrMessage}); if (!nameOrOk) checks.push({name: okOrMessage, ok: nameOrOk});
|
|
}
|
|
}
|
|
if (!isSkip) {
|
|
|
|
/**
|
|
* Composite type → the archive directory holding that component's art.
|
|
*
|
|
* A COF layer record stores only the composite type and a weapon class, so this
|
|
* is the mapping that turns "layer 6 is a head" into `.../hd/...`. Objects use
|
|
* the same table: the shipped object COFs declare type 1, whose art lives in the
|
|
* `tr` directory, exactly as a character torso does.
|
|
*/
|
|
const LAYER_COMPONENT = [
|
|
'hd',
|
|
'tr',
|
|
'lg',
|
|
'ra',
|
|
'la',
|
|
'rh',
|
|
'lh',
|
|
'sh',
|
|
's1',
|
|
's2',
|
|
's3',
|
|
's4',
|
|
's5',
|
|
's6',
|
|
's7',
|
|
's8',
|
|
]
|
|
|
|
/** Archive holding the character art, opened first because it gates the run. */
|
|
const CHARACTER_ARCHIVE = 'd2char.mpq'
|
|
/** Archive holding object art, palettes and object COFs. */
|
|
const DATA_ARCHIVE = 'd2data.mpq'
|
|
/** The class COFs this project needs before the Sorceress can be drawn. */
|
|
const CLASS_ANIMATIONS: readonly { readonly label: string; readonly member: string }[] = [
|
|
{ label: 'walk', member: 'data\\global\\chars\\so\\cof\\sowlhth.cof' },
|
|
{ label: 'stand', member: 'data\\global\\chars\\so\\cof\\sonuhth.cof' },
|
|
]
|
|
/** Object members decoded directly, with no COF involved. */
|
|
const OBJECT_DCCS: readonly string[] = [
|
|
'data\\global\\objects\\c5\\tr\\c5trlitnuhth.dcc',
|
|
'data\\global\\objects\\l2\\tr\\l2trlitnuhth.dcc',
|
|
]
|
|
/** Object COF decoded to exercise the COF→DCC path outside the character tree. */
|
|
const OBJECT_COFS: readonly { readonly label: string; readonly member: string }[] = [
|
|
{ label: 'object l2 neutral', member: 'data\\global\\objects\\l2\\cof\\l2nuhth.cof' },
|
|
]
|
|
/** Directory whose DCCs the bottom-up sweep covers. */
|
|
const SWEEP_PREFIX = 'data/global/chars/so/'
|
|
/** Sweep cap in `--quick` mode, so the script stays usable in a loop. */
|
|
const QUICK_LIMIT = 150
|
|
/** Cap on individually printed sweep failures. */
|
|
const MAX_REPORTED_SWEEP_FAILURES = 20
|
|
/** Brightness ramp for the text rendering; index 0 is reserved for empty space. */
|
|
const ASCII_RAMP = ' .:-=+*#%@'
|
|
/** Cap on the ASCII rendering's width and height, in characters. */
|
|
const ASCII_COLUMNS = 50
|
|
const ASCII_ROWS = 30
|
|
|
|
/** What one decoded DCC member contributed, for the running totals. */
|
|
interface MemberStats {
|
|
readonly member: string
|
|
readonly directions: number
|
|
readonly frames: number
|
|
readonly bottomUp: number
|
|
readonly minArtWidth: number
|
|
readonly maxArtWidth: number
|
|
readonly minArtHeight: number
|
|
readonly maxArtHeight: number
|
|
readonly minCanvasWidth: number
|
|
readonly maxCanvasWidth: number
|
|
readonly minCanvasHeight: number
|
|
readonly maxCanvasHeight: number
|
|
readonly opaquePixels: number
|
|
readonly totalPixels: number
|
|
}
|
|
|
|
/** A decoded member plus its statistics, so callers can reuse the artwork. */
|
|
interface DccResult {
|
|
readonly stats: MemberStats
|
|
readonly dcc: DccFile
|
|
}
|
|
|
|
const args = ['samples/fixtures']
|
|
const dir = args.find((a) => !a.startsWith('--')) ?? 'samples/d2'
|
|
const quick = args.includes('--quick')
|
|
|
|
let membersDecoded = 0
|
|
let cofsDecoded = 0
|
|
let directions = 0
|
|
let frames = 0
|
|
let bottomUpFrames = 0
|
|
let opaquePixels = 0
|
|
let totalPixels = 0
|
|
let minArtWidth = Number.MAX_SAFE_INTEGER
|
|
let maxArtWidth = 0
|
|
let minArtHeight = Number.MAX_SAFE_INTEGER
|
|
let maxArtHeight = 0
|
|
let minCanvasWidth = Number.MAX_SAFE_INTEGER
|
|
let maxCanvasWidth = 0
|
|
let minCanvasHeight = Number.MAX_SAFE_INTEGER
|
|
let maxCanvasHeight = 0
|
|
let assertions = 0
|
|
let failures = 0
|
|
/** A frame captured for the text rendering, with the member it came from. */
|
|
let renderSample: { member: string; frame: SpriteFrame; direction: number; index: number } | undefined
|
|
|
|
/**
|
|
* Map an archive name to the lower-case, forward-slash form used for matching.
|
|
*
|
|
* @param name - the name as stored.
|
|
* @returns the comparison form.
|
|
*/
|
|
function normalize(name: string): string {
|
|
return name.replaceAll('\\', '/').toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* Extract a message from an unknown thrown value.
|
|
*
|
|
* @param err - the thrown value.
|
|
* @returns the message.
|
|
*/
|
|
function messageOf(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err)
|
|
}
|
|
|
|
/**
|
|
* Record one assertion.
|
|
*
|
|
* @param ok - whether it held.
|
|
* @param message - what was checked.
|
|
* @param required - whether a failure should fail the run.
|
|
*/
|
|
function __check_disabled(ok: boolean, message: string, required = true): void {
|
|
assertions += 1
|
|
if (ok) return
|
|
if (required) failures += 1
|
|
console.log(` FAIL ${message}`)
|
|
}
|
|
|
|
/**
|
|
* Reconstruct the DCC path a COF layer draws.
|
|
*
|
|
* A COF layer record names no file: it carries a composite type and a weapon
|
|
* class, and the COF's own file name carries the animation and weapon codes. The
|
|
* sprite path is therefore
|
|
* `<root><component>/<token><component><variant><animation><weapon>.dcc`, where
|
|
* everything except `<variant>` is known. `<variant>` is the armour or object
|
|
* variant code, which lives in the item tables rather than the COF, so the
|
|
* lexicographically first candidate is used: every candidate is the same
|
|
* animation from a different armour tier, and decoding one of them is what this
|
|
* check is for.
|
|
*
|
|
* @param names - the archive's normalised name list.
|
|
* @param root - directory prefix shared by the COF and its art.
|
|
* @param token - the object or class code, e.g. `so`.
|
|
* @param animation - two-letter animation code from the COF name.
|
|
* @param weapon - weapon-class code from the COF name.
|
|
* @param component - component directory for the layer's composite type.
|
|
* @returns the member name, or undefined when the archive has no candidate.
|
|
*/
|
|
function findLayerDcc(
|
|
names: readonly string[],
|
|
root: string,
|
|
token: string,
|
|
animation: string,
|
|
weapon: string,
|
|
component: string,
|
|
): string | undefined {
|
|
const prefix = `${root}${component}/${token}${component}`
|
|
const suffix = `${animation}${weapon}.dcc`
|
|
const hits = names.filter((n) => n.startsWith(prefix) && n.endsWith(suffix))
|
|
hits.sort()
|
|
return hits[0]
|
|
}
|
|
|
|
/**
|
|
* Decode one COF.
|
|
*
|
|
* @param archive - the archive holding the member.
|
|
* @param member - the member name.
|
|
* @param required - whether a failure should fail the run.
|
|
* @returns the decoded COF, or undefined when it could not be read.
|
|
*/
|
|
async function readCof(archive: MpqArchive, member: string, required: boolean): Promise<CofFile | undefined> {
|
|
const file = archive.find(member)
|
|
if (file === undefined) {
|
|
check(false, `${member}: not present in the archive`, required)
|
|
return undefined
|
|
}
|
|
let data: Uint8Array
|
|
try {
|
|
data = await archive.read(file!)
|
|
} catch (err) {
|
|
check(false, `${member}: cannot read member: ${messageOf(err)}`, required)
|
|
return undefined
|
|
}
|
|
try {
|
|
const cof = decodeCof(data)
|
|
check(true, `${member}: decoded`)
|
|
cofsDecoded += 1
|
|
return cof
|
|
} catch (err) {
|
|
check(false, `${member}: decode failed: ${messageOf(err)}`, required)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode one DCC member, assert every frame is usable, and gather its statistics.
|
|
*
|
|
* The per-frame contract is deliberately strict — positive canvas, positive art
|
|
* rectangle, at least one opaque pixel — because those three properties between
|
|
* them rule out the failure modes that a decoder merely "succeeding" would
|
|
* otherwise hide: an empty canvas, an off-by-one box, and a frame that decoded
|
|
* to nothing but transparency.
|
|
*
|
|
* @param archive - the archive holding the member.
|
|
* @param member - the member name.
|
|
* @param expected - direction and frame counts the COF demands, when known.
|
|
* @param required - whether a failure should fail the run.
|
|
* @returns the member's statistics, or undefined when it could not be decoded.
|
|
*/
|
|
async function checkDcc(
|
|
archive: MpqArchive,
|
|
member: string,
|
|
expected: { directions: number; frames: number } | undefined,
|
|
required: boolean,
|
|
): Promise<DccResult | undefined> {
|
|
const file = archive.find(member)
|
|
if (file === undefined) {
|
|
check(false, `${member}: not present in the archive`, required)
|
|
return undefined
|
|
}
|
|
let data: Uint8Array
|
|
try {
|
|
data = await archive.read(file!)
|
|
} catch (err) {
|
|
check(false, `${member}: cannot read member: ${messageOf(err)}`, required)
|
|
return undefined
|
|
}
|
|
let dcc: DccFile
|
|
try {
|
|
dcc = decodeDcc(data)
|
|
} catch (err) {
|
|
check(false, `${member}: decode failed: ${messageOf(err)}`, required)
|
|
return undefined
|
|
}
|
|
|
|
check(dcc.directions.length > 0, `${member}: decoded ${String(dcc.directions.length)} directions`, required)
|
|
if (expected !== undefined) {
|
|
check(
|
|
dcc.directions.length === expected.directions,
|
|
`${member}: ${String(dcc.directions.length)} directions, COF declares ${String(expected.directions)}`,
|
|
required,
|
|
)
|
|
}
|
|
|
|
let memberFrames = 0
|
|
let memberBottomUp = 0
|
|
let memberOpaque = 0
|
|
let memberTotal = 0
|
|
let artMinW = Number.MAX_SAFE_INTEGER
|
|
let artMaxW = 0
|
|
let artMinH = Number.MAX_SAFE_INTEGER
|
|
let artMaxH = 0
|
|
let canvasMinW = Number.MAX_SAFE_INTEGER
|
|
let canvasMaxW = 0
|
|
let canvasMinH = Number.MAX_SAFE_INTEGER
|
|
let canvasMaxH = 0
|
|
|
|
for (let d = 0; d < dcc.directions.length; d += 1) {
|
|
const directionBox = dcc.directions[d]!.box
|
|
const list = dcc.directions[d]!.frames
|
|
check(
|
|
directionBox.width > 0 && directionBox.height > 0,
|
|
`${member} direction ${String(d)}: box ${String(directionBox.width)}x${String(directionBox.height)}`,
|
|
required,
|
|
)
|
|
if (expected !== undefined) {
|
|
check(
|
|
list.length === expected.frames,
|
|
`${member} direction ${String(d)}: ${String(list.length)} frames, COF declares ${String(expected.frames)}`,
|
|
required,
|
|
)
|
|
}
|
|
if (directionBox.width <= 0 || directionBox.height <= 0) continue
|
|
canvasMinW = Math.min(canvasMinW, directionBox.width)
|
|
canvasMaxW = Math.max(canvasMaxW, directionBox.width)
|
|
canvasMinH = Math.min(canvasMinH, directionBox.height)
|
|
canvasMaxH = Math.max(canvasMaxH, directionBox.height)
|
|
|
|
for (let f = 0; f < list.length; f += 1) {
|
|
const decoded = list[f]!
|
|
const sprite = decoded.frame
|
|
memberFrames += 1
|
|
if (decoded.bottomUp) memberBottomUp += 1
|
|
check(
|
|
decoded.width > 0 && decoded.height > 0,
|
|
`${member} direction ${String(d)} frame ${String(f)}: art ${String(decoded.width)}x${String(decoded.height)}`,
|
|
required,
|
|
)
|
|
check(
|
|
sprite.width === directionBox.width && sprite.height === directionBox.height,
|
|
`${member} direction ${String(d)} frame ${String(f)}: canvas ${String(sprite.width)}x${String(sprite.height)} is not the direction box`,
|
|
required,
|
|
)
|
|
if (decoded.width <= 0 || decoded.height <= 0) continue
|
|
artMinW = Math.min(artMinW, decoded.width)
|
|
artMaxW = Math.max(artMaxW, decoded.width)
|
|
artMinH = Math.min(artMinH, decoded.height)
|
|
artMaxH = Math.max(artMaxH, decoded.height)
|
|
|
|
let opaque = 0
|
|
for (const m of sprite.mask) if (m !== 0) opaque += 1
|
|
memberOpaque += opaque
|
|
memberTotal += sprite.mask.length
|
|
check(
|
|
opaque > 0,
|
|
`${member} direction ${String(d)} frame ${String(f)}: no opaque pixels in ${String(sprite.width)}x${String(sprite.height)}`,
|
|
required,
|
|
)
|
|
}
|
|
}
|
|
|
|
membersDecoded += 1
|
|
directions += dcc.directions.length
|
|
frames += memberFrames
|
|
bottomUpFrames += memberBottomUp
|
|
opaquePixels += memberOpaque
|
|
totalPixels += memberTotal
|
|
minArtWidth = Math.min(minArtWidth, artMinW)
|
|
maxArtWidth = Math.max(maxArtWidth, artMaxW)
|
|
minArtHeight = Math.min(minArtHeight, artMinH)
|
|
maxArtHeight = Math.max(maxArtHeight, artMaxH)
|
|
minCanvasWidth = Math.min(minCanvasWidth, canvasMinW)
|
|
maxCanvasWidth = Math.max(maxCanvasWidth, canvasMaxW)
|
|
minCanvasHeight = Math.min(minCanvasHeight, canvasMinH)
|
|
maxCanvasHeight = Math.max(maxCanvasHeight, canvasMaxH)
|
|
|
|
return {
|
|
stats: {
|
|
member,
|
|
directions: dcc.directions.length,
|
|
frames: memberFrames,
|
|
bottomUp: memberBottomUp,
|
|
minArtWidth: artMinW,
|
|
maxArtWidth: artMaxW,
|
|
minArtHeight: artMinH,
|
|
maxArtHeight: artMaxH,
|
|
minCanvasWidth: canvasMinW,
|
|
maxCanvasWidth: canvasMaxW,
|
|
minCanvasHeight: canvasMinH,
|
|
maxCanvasHeight: canvasMaxH,
|
|
opaquePixels: memberOpaque,
|
|
totalPixels: memberTotal,
|
|
},
|
|
dcc,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Print one decoded member's measurements on a fixed-width line.
|
|
*
|
|
* @param label - the layer description.
|
|
* @param stats - the member's statistics.
|
|
*/
|
|
function reportMember(label: string, stats: MemberStats): void {
|
|
const ratio = stats.totalPixels === 0 ? 0 : (100 * stats.opaquePixels) / stats.totalPixels
|
|
const dim = (w: number, h: number): string => `${String(w)}x${String(h)}`
|
|
console.log(
|
|
` ${label.padEnd(16)} ${`${String(stats.directions)}x${String(stats.frames)}`.padStart(7)}` +
|
|
` art ${`${dim(stats.minArtWidth, stats.minArtHeight)}`.padStart(8)}..${dim(stats.maxArtWidth, stats.maxArtHeight).padEnd(8)}` +
|
|
` canvas ${`${dim(stats.minCanvasWidth, stats.minCanvasHeight)}`.padStart(8)}..${dim(stats.maxCanvasWidth, stats.maxCanvasHeight).padEnd(8)}` +
|
|
` opaque ${ratio.toFixed(1).padStart(5)}% bottom-up ${String(stats.bottomUp)}`,
|
|
)
|
|
console.log(` ${stats.member}`)
|
|
}
|
|
|
|
/**
|
|
* Decode a COF and every DCC its layers name.
|
|
*
|
|
* @param archive - the archive holding the member.
|
|
* @param names - the archive's normalised name list.
|
|
* @param label - the animation's label.
|
|
* @param member - the COF member name.
|
|
* @param root - the normalised directory prefix shared by the COF and its art.
|
|
* @param token - the class or object code, e.g. `so`.
|
|
* @param required - whether failures should fail the run.
|
|
* @param captureLayer - component whose first frame is kept for the text render.
|
|
*/
|
|
async function checkCofAndLayers(
|
|
archive: MpqArchive,
|
|
names: readonly string[],
|
|
label: string,
|
|
member: string,
|
|
root: string,
|
|
token: string,
|
|
required: boolean,
|
|
captureLayer?: string,
|
|
): Promise<void> {
|
|
const cof = await readCof(archive, member, required)
|
|
if (cof === undefined) return
|
|
|
|
const base = normalize(member).split('/').pop()!.replace('.cof', '')
|
|
const animation = base.slice(token.length, token.length + 2)
|
|
const weapon = base.slice(token.length + 2)
|
|
console.log(`\n[${label}] ${member}`)
|
|
console.log(
|
|
` cof: ${String(cof.numberOfDirections)} directions x ${String(cof.framesPerDirection)} frames ` +
|
|
`x ${String(cof.numberOfLayers)} layers, speed ${String(cof.speed)}, animation code ${animation}${weapon}`,
|
|
)
|
|
|
|
// The priority table must account for every layer: anything else means a
|
|
// layer's art is never drawn, which a screenshot would not reveal.
|
|
for (let d = 0; d < cof.numberOfDirections; d += 1) {
|
|
for (let f = 0; f < cof.framesPerDirection; f += 1) {
|
|
const order = cofLayerOrder(cof, d, f)
|
|
check(
|
|
order.length === cof.numberOfLayers,
|
|
`${member} direction ${String(d)} frame ${String(f)}: layer order has ${String(order.length)} of ${String(cof.numberOfLayers)} layers`,
|
|
required,
|
|
)
|
|
for (const index of order) {
|
|
check(
|
|
index >= 0 && index < cof.numberOfLayers,
|
|
`${member} direction ${String(d)} frame ${String(f)}: layer order names index ${String(index)}`,
|
|
required,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
console.log(` layer order dir 0 frame 0 -> [${cofLayerOrder(cof, 0, 0).join(', ')}]`)
|
|
|
|
const expected = { directions: cof.numberOfDirections, frames: cof.framesPerDirection }
|
|
for (let i = 0; i < cof.layers.length; i += 1) {
|
|
const layer = cof.layers[i]!
|
|
const component = LAYER_COMPONENT[layer.type]
|
|
check(component !== undefined, `${member} layer ${String(i)}: composite type ${String(layer.type)}`, required)
|
|
if (component === undefined) continue
|
|
const layerMember = findLayerDcc(names, root, token, animation, weapon, component)
|
|
check(
|
|
layerMember !== undefined,
|
|
`${member} layer ${String(i)} (type ${String(layer.type)} → ${component}): no ${animation}${weapon}.dcc candidate`,
|
|
required,
|
|
)
|
|
if (layerMember === undefined) continue
|
|
const result = await checkDcc(archive, layerMember, expected, required)
|
|
if (result === undefined) continue
|
|
reportMember(`layer ${String(i)} ${component} type ${String(layer.type)}`, result.stats)
|
|
if (captureLayer === component && renderSample === undefined) {
|
|
renderSample = {
|
|
member: layerMember,
|
|
frame: result.dcc.directions[0]!.frames[0]!.frame,
|
|
direction: 0,
|
|
index: 0,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map a palette index to a position on the brightness ramp.
|
|
*
|
|
* With a real palette the value is the colour's luminance, so the rendering shows
|
|
* the art's own shading; without one the index itself stands in for brightness,
|
|
* which is still enough to tell a shape from noise.
|
|
*
|
|
* @param index - the palette index.
|
|
* @param palette - the palette, when one could be read.
|
|
* @returns a ramp index of 1 or more (0 is reserved for transparent pixels).
|
|
*/
|
|
function brightnessLevel(index: number, palette: Palette | undefined): number {
|
|
let luminance = index
|
|
if (palette !== undefined) {
|
|
const at = index * 3
|
|
luminance = 0.299 * palette.rgb[at]! + 0.587 * palette.rgb[at + 1]! + 0.114 * palette.rgb[at + 2]!
|
|
}
|
|
const steps = ASCII_RAMP.length - 1
|
|
return 1 + Math.min(steps - 1, Math.floor((luminance / 256) * steps))
|
|
}
|
|
|
|
/**
|
|
* Render a frame as text.
|
|
*
|
|
* Images cannot be inspected in this environment, so a coarse text rendering is
|
|
* how a human confirms the decoder produced a shape rather than noise. Sampling
|
|
* every Nth pixel keeps the output inside the column and row caps.
|
|
*
|
|
* @param frame - the decoded frame.
|
|
* @param palette - the palette, when one could be read.
|
|
* @returns one string per sampled row.
|
|
*/
|
|
function renderAscii(frame: SpriteFrame, palette: Palette | undefined): string[] {
|
|
const stepX = Math.max(1, Math.ceil(frame.width / ASCII_COLUMNS))
|
|
const stepY = Math.max(1, Math.ceil(frame.height / ASCII_ROWS))
|
|
const lines: string[] = []
|
|
for (let y = 0; y < frame.height; y += stepY) {
|
|
let line = ''
|
|
for (let x = 0; x < frame.width; x += stepX) {
|
|
const at = y * frame.width + x
|
|
line += frame.mask[at] === 0 ? ' ' : ASCII_RAMP[brightnessLevel(frame.indices[at]!, palette)]!
|
|
}
|
|
lines.push(line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
/**
|
|
* Sweep every DCC under the Sorceress tree, counting the frames that take the
|
|
* bottom-up path and reporting members that fail to decode.
|
|
*
|
|
* @param archive - the character archive.
|
|
* @param names - its normalised name list.
|
|
* @param palette - the palette, unused by the count but kept for symmetry.
|
|
* @returns a one-line summary.
|
|
*/
|
|
async function sweepSorceress(archive: MpqArchive, names: readonly string[]): Promise<string> {
|
|
const all = names.filter((n) => n.startsWith(SWEEP_PREFIX) && n.endsWith('.dcc'))
|
|
const selected = quick ? all.slice(0, QUICK_LIMIT) : all
|
|
const started = Date.now()
|
|
let sweptFrames = 0
|
|
let sweptBottomUp = 0
|
|
const reasons = new Map<string, number>()
|
|
let failed = 0
|
|
|
|
for (const member of selected) {
|
|
try {
|
|
const dcc = decodeDcc(await archive.read(archive.find(member)!))
|
|
for (const direction of dcc.directions) {
|
|
for (const frame of direction.frames) {
|
|
sweptFrames += 1
|
|
if (frame.bottomUp) sweptBottomUp += 1
|
|
}
|
|
}
|
|
} catch (err) {
|
|
failed += 1
|
|
// Collapse the varying numbers so identical failures group together.
|
|
const reason = `${member}: ${messageOf(err)}`.replace(/\d+/g, 'N')
|
|
reasons.set(reason, (reasons.get(reason) ?? 0) + 1)
|
|
if (reasons.size <= MAX_REPORTED_SWEEP_FAILURES) {
|
|
console.log(` FAIL ${member}: ${messageOf(err)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const seconds = ((Date.now() - started) / 1000).toFixed(1)
|
|
let line = ` ${String(selected.length)} members, ${String(sweptFrames)} frames, ${String(sweptBottomUp)} bottom-up, ${String(failed)} failed (${seconds}s)`
|
|
if (failed > reasons.size) line += `, ${String(failed - reasons.size)} more of the same`
|
|
if (quick && all.length > selected.length) line += ` [--quick: ${String(all.length - selected.length)} members not swept]`
|
|
return line
|
|
}
|
|
|
|
console.log(`== cof + dcc verification over ${dir}${quick ? ' (quick)' : ''} ==`)
|
|
|
|
const characterPath = `${dir}/${CHARACTER_ARCHIVE}`
|
|
let characters: any = null;
|
|
try {
|
|
characters = await MpqArchive.open(await fileSource(characterPath))
|
|
} catch (err) {
|
|
console.log(`cannot open ${characterPath}: ${messageOf(err)}`)
|
|
// disabled exit: 2)
|
|
}
|
|
const characterNames = (await characters.listFiles()).map(normalize)
|
|
console.log(`${CHARACTER_ARCHIVE}: ${String(characterNames.length)} members listed`)
|
|
|
|
for (const animation of CLASS_ANIMATIONS) {
|
|
await checkCofAndLayers(
|
|
characters,
|
|
characterNames,
|
|
`sorceress ${animation.label}`,
|
|
animation.member,
|
|
'data/global/chars/so/',
|
|
'so',
|
|
true,
|
|
animation.label === 'stand' ? 'tr' : undefined,
|
|
)
|
|
}
|
|
|
|
let data: MpqArchive | undefined
|
|
try {
|
|
data = await MpqArchive.open(await fileSource(`${dir}/${DATA_ARCHIVE}`))
|
|
} catch (err) {
|
|
console.log(`\nnote: ${DATA_ARCHIVE} unavailable, object members skipped (${messageOf(err)})`)
|
|
}
|
|
|
|
if (data !== undefined) {
|
|
const dataNames = (await data.listFiles()).map(normalize)
|
|
console.log(`\n${DATA_ARCHIVE}: ${String(dataNames.length)} members listed`)
|
|
for (const animation of OBJECT_COFS) {
|
|
await checkCofAndLayers(
|
|
data,
|
|
dataNames,
|
|
animation.label,
|
|
animation.member,
|
|
'data/global/objects/l2/',
|
|
'l2',
|
|
false,
|
|
)
|
|
}
|
|
console.log('\n[object sprites]')
|
|
for (const member of OBJECT_DCCS) {
|
|
const result = await checkDcc(data, member, undefined, false)
|
|
if (result !== undefined) reportMember('object', result.stats)
|
|
}
|
|
}
|
|
|
|
let palette: Palette | undefined
|
|
if (data !== undefined) {
|
|
const palFile = data.find('data\\global\\palette\\act1\\pal.dat')
|
|
if (palFile !== undefined) {
|
|
try {
|
|
palette = decodePal(await data.read(palFile))
|
|
} catch (err) {
|
|
console.log(`note: act 1 palette unreadable: ${messageOf(err)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('\n[sweep: sorceress art, every direction of every member]')
|
|
console.log(await sweepSorceress(characters, characterNames))
|
|
|
|
console.log('\n== summary ==')
|
|
console.log(` cofs decoded: ${String(cofsDecoded)}`)
|
|
console.log(` dcc members: ${String(membersDecoded)}`)
|
|
console.log(` directions: ${String(directions)}`)
|
|
console.log(` frames: ${String(frames)}`)
|
|
console.log(
|
|
` art rectangles: ${`${String(minArtWidth)}x${String(minArtHeight)}`} .. ${`${String(maxArtWidth)}x${String(maxArtHeight)}`} px`,
|
|
)
|
|
console.log(
|
|
` direction canvases: ${`${String(minCanvasWidth)}x${String(minCanvasHeight)}`} .. ${`${String(maxCanvasWidth)}x${String(maxCanvasHeight)}`} px`,
|
|
)
|
|
console.log(` bottom-up frames: ${String(bottomUpFrames)} of ${String(frames)} checked`)
|
|
console.log(
|
|
` opaque coverage: ${((100 * opaquePixels) / Math.max(1, totalPixels)).toFixed(1)}% of ${String(totalPixels)} canvas pixels`,
|
|
)
|
|
console.log(` assertions: ${String(assertions - failures)}/${String(assertions)} passed`)
|
|
|
|
if (renderSample !== undefined) {
|
|
const sample = renderSample
|
|
const stepX = Math.max(1, Math.ceil(sample.frame.width / ASCII_COLUMNS))
|
|
const stepY = Math.max(1, Math.ceil(sample.frame.height / ASCII_ROWS))
|
|
let opaque = 0
|
|
for (const m of sample.frame.mask) if (m !== 0) opaque += 1
|
|
console.log(`\n== text rendering: ${sample.member} direction ${String(sample.direction)} frame ${String(sample.index)} ==`)
|
|
console.log(
|
|
` ${String(sample.frame.width)}x${String(sample.frame.height)} px, sampling every ${String(stepX)}x${String(stepY)} px, ` +
|
|
`${((100 * opaque) / sample.frame.mask.length).toFixed(1)}% opaque, ' ' = transparent, '@' = brightest`,
|
|
)
|
|
for (const line of renderAscii(sample.frame, palette)) console.log(` |${line}|`)
|
|
}
|
|
|
|
if (failures > 0) {
|
|
console.log(`\n${String(failures)} failures: the COF+DCC path is not usable yet`)
|
|
// disabled exit: 1)
|
|
}
|
|
console.log('\nall required checks passed')
|
|
|
|
suiteCompleted = true;
|
|
}
|
|
describe('verify-dcc.ts', () => {
|
|
test.skipIf(isSkip)('evaluates script successfully', () => {
|
|
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
|
});
|
|
test.skipIf(isSkip)('runs assertion', () => {
|
|
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
|
let resKey = _results.findIndex(x => x.desc.startsWith('runs assertion'));
|
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
|
else vitestExpect(true).toBe(true);
|
|
});
|
|
}); |