diablo2-web/src/main.ts

231 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'))
})()
}