231 lines
9.3 KiB
TypeScript
231 lines
9.3 KiB
TypeScript
/**
|
||
* Browser entry: open a user-supplied MPQ and inspect what the container layer
|
||
* decodes.
|
||
*
|
||
* This is the first visible milestone (M0): it proves the whole pipeline runs
|
||
* web-native — random-access reads over a `File` (never loading the archive
|
||
* into memory), table decryption, sector decompression, and format previews
|
||
* rendered by platform APIs (canvas for images, Web Audio for sound).
|
||
*/
|
||
import { MpqArchive } from './mpq/archive.ts'
|
||
import { blobSource } from './mpq/source.ts'
|
||
import { decodePcx } from './formats/pcx.ts'
|
||
|
||
const drop = document.querySelector<HTMLDivElement>('#drop')!
|
||
const picker = document.querySelector<HTMLInputElement>('#picker')!
|
||
const stats = document.querySelector<HTMLDListElement>('#stats')!
|
||
const filter = document.querySelector<HTMLInputElement>('#filter')!
|
||
const list = document.querySelector<HTMLUListElement>('#files')!
|
||
const preview = document.querySelector<HTMLElement>('#preview')!
|
||
|
||
/** Extension without the dot, lowercased. */
|
||
function extensionOf(name: string): string {
|
||
const cut = name.lastIndexOf('.')
|
||
return cut === -1 ? '' : name.slice(cut + 1).toLowerCase()
|
||
}
|
||
|
||
/** Human-readable byte count. */
|
||
function bytes(count: number): string {
|
||
if (count < 1024) return `${String(count)} B`
|
||
if (count < 1024 * 1024) return `${(count / 1024).toFixed(1)} KB`
|
||
return `${(count / 1024 / 1024).toFixed(2)} MB`
|
||
}
|
||
|
||
/** Render a definition list of label/value pairs. */
|
||
function renderStats(rows: readonly (readonly [string, string])[]): void {
|
||
stats.replaceChildren(...rows.flatMap(([label, value]) => {
|
||
const dt = document.createElement('dt')
|
||
dt.textContent = label
|
||
const dd = document.createElement('dd')
|
||
dd.textContent = value
|
||
return [dt, dd]
|
||
}))
|
||
}
|
||
|
||
/** Draw an RGBA image into a fresh canvas, scaled by whole pixels. */
|
||
function imageCanvas(width: number, height: number, rgba: Uint8ClampedArray, scale: number): HTMLCanvasElement {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = width * scale
|
||
canvas.height = height * scale
|
||
const context = canvas.getContext('2d')!
|
||
context.imageSmoothingEnabled = false
|
||
const source = document.createElement('canvas')
|
||
source.width = width
|
||
source.height = height
|
||
// `ImageData` demands an `ArrayBuffer`-backed view; a decoded image's RGBA
|
||
// array is only typed as possibly-shared, so copy it into a fresh one.
|
||
const backing = new Uint8ClampedArray(new ArrayBuffer(width * height * 4))
|
||
backing.set(rgba)
|
||
source.getContext('2d')!.putImageData(new ImageData(backing, width, height), 0, 0)
|
||
context.drawImage(source, 0, 0, canvas.width, canvas.height)
|
||
return canvas
|
||
}
|
||
|
||
/** True when the bytes look like text rather than binary. */
|
||
function looksTextual(data: Uint8Array): boolean {
|
||
const sample = data.subarray(0, Math.min(data.byteLength, 512))
|
||
let printable = 0
|
||
for (const byte of sample) {
|
||
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 0x20 && byte < 0x7f) || byte >= 0x80) printable += 1
|
||
}
|
||
return sample.byteLength > 0 && printable / sample.byteLength > 0.9
|
||
}
|
||
|
||
/** Hex + ASCII dump of the first bytes. */
|
||
function hexDump(data: Uint8Array, limit = 512): string {
|
||
const lines: string[] = []
|
||
const end = Math.min(data.byteLength, limit)
|
||
for (let at = 0; at < end; at += 16) {
|
||
const row = data.subarray(at, Math.min(at + 16, end))
|
||
const hex = [...row].map(byte => byte.toString(16).padStart(2, '0')).join(' ')
|
||
const ascii = [...row].map(byte => (byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.')).join('')
|
||
lines.push(`${at.toString(16).padStart(6, '0')} ${hex.padEnd(47)} ${ascii}`)
|
||
}
|
||
return lines.join('\n')
|
||
}
|
||
|
||
let audioContext: AudioContext | null = null
|
||
|
||
/** Show one decoded member. */
|
||
async function showMember(archive: MpqArchive, name: string, button: HTMLButtonElement): Promise<void> {
|
||
for (const other of list.querySelectorAll('button[aria-current]')) other.removeAttribute('aria-current')
|
||
button.setAttribute('aria-current', 'true')
|
||
const file = archive.find(name)
|
||
preview.replaceChildren()
|
||
const title = document.createElement('h2')
|
||
title.textContent = name
|
||
const sub = document.createElement('div')
|
||
sub.className = 'sub'
|
||
preview.append(title, sub)
|
||
if (file === undefined) {
|
||
sub.className = 'sub error'
|
||
sub.textContent = '该名字在归档中解析不到(listfile 与实际条目不匹配)'
|
||
return
|
||
}
|
||
sub.textContent = `${bytes(file.fileSize)} · flags 0x${(file.flags >>> 0).toString(16)} · block #${String(file.blockIndex)}`
|
||
try {
|
||
const data = await archive.read(file)
|
||
const ext = extensionOf(name)
|
||
if (ext === 'pcx') {
|
||
const image = decodePcx(data)
|
||
const scale = Math.max(1, Math.min(4, Math.floor(760 / Math.max(image.width, 1))))
|
||
preview.append(imageCanvas(image.width, image.height, image.rgba, scale))
|
||
const note = document.createElement('div')
|
||
note.className = 'sub'
|
||
note.textContent = `${String(image.width)}×${String(image.height)} · ${String(data.byteLength)} 字节 · PCX 解码正常`
|
||
preview.append(note)
|
||
return
|
||
}
|
||
if (ext === 'wav' || ext === 'mp3' || ext === 'ogg') {
|
||
audioContext ??= new AudioContext()
|
||
// The shareware archive's ".wav" members are actually MP3 payloads, so
|
||
// the platform decoder (not a RIFF parser) is what must read them.
|
||
const buffer = await audioContext.decodeAudioData(data.slice().buffer)
|
||
const play = document.createElement('button')
|
||
play.textContent = '▶ 播放'
|
||
play.onclick = () => {
|
||
const source = audioContext!.createBufferSource()
|
||
source.buffer = buffer
|
||
source.connect(audioContext!.destination)
|
||
source.start()
|
||
}
|
||
const note = document.createElement('div')
|
||
note.className = 'sub'
|
||
note.textContent = `${buffer.numberOfChannels} 声道 · ${buffer.sampleRate} Hz · ${buffer.duration.toFixed(2)} s · Web Audio 解码正常`
|
||
preview.append(play, note)
|
||
return
|
||
}
|
||
if (looksTextual(data)) {
|
||
const pre = document.createElement('pre')
|
||
const text = new TextDecoder().decode(data)
|
||
pre.textContent = text.length > 20000 ? `${text.slice(0, 20000)}\n… 共 ${String(text.length)} 字符` : text
|
||
preview.append(pre)
|
||
return
|
||
}
|
||
const pre = document.createElement('pre')
|
||
pre.textContent = hexDump(data)
|
||
preview.append(pre)
|
||
} catch (error) {
|
||
sub.className = 'sub error'
|
||
sub.textContent = `解码失败:${(error as Error).message}`
|
||
}
|
||
}
|
||
|
||
/** Open an archive from a user-supplied file. */
|
||
async function openArchive(file: File): Promise<void> {
|
||
stats.replaceChildren()
|
||
list.replaceChildren()
|
||
preview.replaceChildren()
|
||
drop.textContent = `正在读取 ${file.name} …`
|
||
try {
|
||
const archive = await MpqArchive.open(blobSource(file))
|
||
const names = await archive.listFiles()
|
||
drop.textContent = `${file.name} · 已就绪(重新拖入可替换)`
|
||
const header = archive.header
|
||
renderStats([
|
||
['文件', file.name],
|
||
['大小', bytes(file.size)],
|
||
['格式', `MPQ v${String(header.formatVersion + 1)}`],
|
||
['扇区', bytes(header.sectorSize)],
|
||
['哈希表', `${String(header.hashTableEntries)} 项`],
|
||
['块表', `${String(header.blockTableEntries)} 项`],
|
||
['名字', `${String(names.length)} 个`],
|
||
])
|
||
filter.disabled = false
|
||
const rows = names.map(name => {
|
||
const item = document.createElement('li')
|
||
const button = document.createElement('button')
|
||
button.textContent = name
|
||
const meta = document.createElement('span')
|
||
meta.className = 'meta'
|
||
const entry = archive.find(name)
|
||
meta.textContent = entry === undefined ? ' (未解析)' : ` ${bytes(entry.fileSize)}`
|
||
button.append(meta)
|
||
button.onclick = () => { void showMember(archive, name, button) }
|
||
item.append(button)
|
||
return { item, name: name.toLowerCase() }
|
||
})
|
||
list.append(...rows.map(row => row.item))
|
||
filter.oninput = () => {
|
||
const needle = filter.value.trim().toLowerCase()
|
||
for (const row of rows) row.item.hidden = needle !== '' && !row.name.includes(needle)
|
||
}
|
||
} catch (error) {
|
||
drop.textContent = '打开失败,重试或换一个归档'
|
||
const message = document.createElement('div')
|
||
message.className = 'error'
|
||
message.textContent = (error as Error).message
|
||
stats.append(message)
|
||
}
|
||
}
|
||
|
||
drop.onclick = () => { picker.click() }
|
||
picker.onchange = () => { const file = picker.files?.[0]; if (file) void openArchive(file) }
|
||
for (const event of ['dragenter', 'dragover'] as const) {
|
||
drop.addEventListener(event, () => { drop.classList.add('hot') })
|
||
}
|
||
for (const event of ['dragleave', 'drop'] as const) {
|
||
drop.addEventListener(event, () => { drop.classList.remove('hot') })
|
||
}
|
||
drop.addEventListener('drop', (event) => {
|
||
event.preventDefault()
|
||
const file = event.dataTransfer?.files?.[0]
|
||
if (file) void openArchive(file)
|
||
})
|
||
|
||
// Dev convenience: `?sample=samples/spawn.mpq` streams a sample archive over
|
||
// HTTP so the pipeline can be smoke-tested without a manual drag & drop.
|
||
const sample = new URLSearchParams(location.search).get('sample')
|
||
if (sample !== null) {
|
||
void (async () => {
|
||
drop.textContent = `正在通过 HTTP 读取 ${sample} …`
|
||
const response = await fetch(sample)
|
||
if (!response.ok) {
|
||
drop.textContent = `样本读取失败:HTTP ${String(response.status)}`
|
||
return
|
||
}
|
||
const blob = await response.blob()
|
||
await openArchive(new File([blob], sample.split('/').pop() ?? 'sample.mpq'))
|
||
})()
|
||
}
|