/** * 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('#drop')! const picker = document.querySelector('#picker')! const stats = document.querySelector('#stats')! const filter = document.querySelector('#filter')! const list = document.querySelector('#files')! const preview = document.querySelector('#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 { 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 { 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')) })() }