Files
paradox-save-parser-frontend/src/lib/saveArchive/gameZip.ts
T
2026-09-23 17:26:40 +02:00

314 lines
11 KiB
TypeScript

// Reading and writing the zip archives the Paradox games themselves use.
//
// This is written here rather than taken from a library because the games are
// picky about the headers and no browser zip library can produce them: they
// all derive the DOS date from a Date and clamp it to 1980, while the games
// write zeros, and the streaming writers emit a data descriptor plus a Zip64
// extra field for every entry, which the games reject. The heavy part of zip,
// deflate, is in the browser itself as CompressionStream('deflate-raw'), so
// what is left is the header layout below.
//
// The field values follow a known good reference, which produces archives the
// games accept: http://pastebin.com/nyX1B4rH
// A real EU4 save agrees with it: MS-DOS as the system of origin, encoding
// software 6.3, extract version 2.0, no data descriptor, no extra field and a
// zeroed DOS date. 7-Zip's NTFS timestamps are a known way to produce a save
// that will not load: https://stellaris.paradoxwikis.com/Save-game_editing
const LOCAL_HEADER_SIGNATURE = 0x04034b50
const CENTRAL_HEADER_SIGNATURE = 0x02014b50
const END_OF_CENTRAL_DIR_SIGNATURE = 0x06054b50
const LOCAL_HEADER_SIZE = 30
const CENTRAL_HEADER_SIZE = 46
const END_OF_CENTRAL_DIR_SIZE = 22
const METHOD_STORED = 0
const METHOD_DEFLATE = 8
// "version needed to extract" 2.0, which is what deflate requires.
const EXTRACT_VERSION = 20
// "version made by": MS-DOS (0) in the high byte, 6.3 (63) in the low byte.
const CREATE_VERSION = (0 << 8) | 63
// Anything at or above this needs the zip64 format, which no save comes near.
const MAX_ENTRY_SIZE = 0xffffffff
// First bytes of every zip archive.
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]
export interface GameZipEntry {
name: string
size: number
}
// The standard CRC-32 lookup table.
const CRC_TABLE = new Uint32Array(256).map((_, i) => {
let value = i
for (let bit = 0; bit < 8; bit++) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
}
return value >>> 0
})
// Continues a CRC-32 over one more chunk. Start with 0; the result is the
// checksum of everything so far, so an entry is summed as it streams past.
function updateCrc(crc: number, data: Uint8Array): number {
let value = ~crc
for (const byte of data) {
value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8)
}
return ~value >>> 0
}
/**
* Tells whether the file is a zip archive, by its magic bytes rather than by
* its extension: the games use the same extension for both an archived and a
* plain text save.
*/
export async function isZipFile(file: Blob): Promise<boolean> {
const header = new Uint8Array(
await file.slice(0, ZIP_MAGIC.length).arrayBuffer()
)
return ZIP_MAGIC.every((byte, i) => header[i] === byte)
}
// Little endian reader over a slice of the archive.
function reader(data: Uint8Array) {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return {
u16: (offset: number) => view.getUint16(offset, true),
u32: (offset: number) => view.getUint32(offset, true),
}
}
interface CentralEntry {
name: string
method: number
compressedSize: number
size: number
localHeaderOffset: number
}
// Reads the central directory, which lists every entry with its size and
// position. Only the last few hundred bytes of the archive are touched.
async function readCentralDirectory(zip: Blob): Promise<CentralEntry[]> {
const end = new Uint8Array(
await zip.slice(zip.size - END_OF_CENTRAL_DIR_SIZE).arrayBuffer()
)
const endFields = reader(end)
if (endFields.u32(0) !== END_OF_CENTRAL_DIR_SIGNATURE) {
throw new Error('Save archive has no end of central directory')
}
const entryCount = endFields.u16(10)
const directoryOffset = endFields.u32(16)
const directory = new Uint8Array(
await zip.slice(directoryOffset, zip.size).arrayBuffer()
)
const fields = reader(directory)
const entries: CentralEntry[] = []
let offset = 0
for (let i = 0; i < entryCount; i++) {
if (fields.u32(offset) !== CENTRAL_HEADER_SIGNATURE) {
throw new Error('Save archive has a corrupted entry header')
}
const nameLength = fields.u16(offset + 28)
const extraLength = fields.u16(offset + 30)
const commentLength = fields.u16(offset + 32)
const nameStart = offset + CENTRAL_HEADER_SIZE
entries.push({
name: new TextDecoder().decode(
directory.subarray(nameStart, nameStart + nameLength)
),
method: fields.u16(offset + 10),
compressedSize: fields.u32(offset + 20),
size: fields.u32(offset + 24),
localHeaderOffset: fields.u32(offset + 42),
})
offset += CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength
}
return entries
}
/**
* Reads every file of a save archive in the order they are stored, asking the
* caller where each one should go and writing it straight into that stream.
*
* Sizes come from the central directory, so they are known before an entry
* starts streaming, and the entry data itself is never held in memory.
*/
export async function readGameZip(
zip: Blob,
openEntry: (entry: GameZipEntry) => WritableStream<Uint8Array>
): Promise<void> {
for (const entry of await readCentralDirectory(zip)) {
// The local header repeats the name and may carry a different extra
// field, so the data can only be located through it.
const header = new Uint8Array(
await zip
.slice(
entry.localHeaderOffset,
entry.localHeaderOffset + LOCAL_HEADER_SIZE
)
.arrayBuffer()
)
const fields = reader(header)
if (fields.u32(0) !== LOCAL_HEADER_SIGNATURE) {
throw new Error(`Save archive entry "${entry.name}" is corrupted`)
}
const dataStart =
entry.localHeaderOffset +
LOCAL_HEADER_SIZE +
fields.u16(26) +
fields.u16(28)
const data = zip.slice(dataStart, dataStart + entry.compressedSize)
if (entry.method === METHOD_STORED) {
await data.stream().pipeTo(openEntry(entry))
} else if (entry.method === METHOD_DEFLATE) {
await data
.stream()
.pipeThrough(new DecompressionStream('deflate-raw'))
.pipeTo(openEntry(entry))
} else {
throw new Error(
`Save archive entry "${entry.name}" uses compression ` +
`method ${entry.method}, which the games do not produce`
)
}
}
}
// Writes a local file header. The date and time fields stay zero, which is
// what the games write and what a Date cannot express.
function localHeader(entry: WrittenEntry): Uint8Array<ArrayBuffer> {
const header = new Uint8Array(LOCAL_HEADER_SIZE + entry.nameBytes.length)
const view = new DataView(header.buffer)
view.setUint32(0, LOCAL_HEADER_SIGNATURE, true)
view.setUint16(4, EXTRACT_VERSION, true)
view.setUint16(8, METHOD_DEFLATE, true)
view.setUint32(14, entry.crc, true)
view.setUint32(18, entry.compressedSize, true)
view.setUint32(22, entry.size, true)
view.setUint16(26, entry.nameBytes.length, true)
header.set(entry.nameBytes, LOCAL_HEADER_SIZE)
return header
}
// Writes the central directory record for one entry.
function centralHeader(entry: WrittenEntry): Uint8Array<ArrayBuffer> {
const header = new Uint8Array(CENTRAL_HEADER_SIZE + entry.nameBytes.length)
const view = new DataView(header.buffer)
view.setUint32(0, CENTRAL_HEADER_SIGNATURE, true)
view.setUint16(4, CREATE_VERSION, true)
view.setUint16(6, EXTRACT_VERSION, true)
view.setUint16(10, METHOD_DEFLATE, true)
view.setUint32(16, entry.crc, true)
view.setUint32(20, entry.compressedSize, true)
view.setUint32(24, entry.size, true)
view.setUint16(28, entry.nameBytes.length, true)
view.setUint32(42, entry.localHeaderOffset, true)
header.set(entry.nameBytes, CENTRAL_HEADER_SIZE)
return header
}
// Writes the end of central directory record that closes the archive.
function endOfCentralDirectory(
entries: WrittenEntry[],
directoryOffset: number,
directorySize: number
): Uint8Array<ArrayBuffer> {
const record = new Uint8Array(END_OF_CENTRAL_DIR_SIZE)
const view = new DataView(record.buffer)
view.setUint32(0, END_OF_CENTRAL_DIR_SIGNATURE, true)
view.setUint16(8, entries.length, true)
view.setUint16(10, entries.length, true)
view.setUint32(12, directorySize, true)
view.setUint32(16, directoryOffset, true)
return record
}
interface WrittenEntry {
nameBytes: Uint8Array
crc: number
size: number
compressedSize: number
localHeaderOffset: number
}
/**
* Builds a save archive the games accept, one streamed file at a time.
*/
export function createGameZipBuilder() {
const entries: WrittenEntry[] = []
// Headers are plain bytes and entry data is a blob, so assembling the
// archive at the end copies nothing through memory.
const parts: BlobPart[] = []
let offset = 0
return {
async add(name: string, body: ReadableStream<Uint8Array>) {
const nameBytes = new TextEncoder().encode(name)
let crc = 0
let size = 0
// Sums the entry as it goes past. The cast only drops the shared
// buffer case from the type; a save never arrives in one.
const summed = body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array<ArrayBuffer>>({
transform(chunk, controller) {
crc = updateCrc(crc, chunk)
size += chunk.length
controller.enqueue(chunk as Uint8Array<ArrayBuffer>)
},
})
)
// The sizes and the checksum belong in the local header, which is
// written before the data, so the entry is deflated first. Only
// the compressed bytes are kept, and in a blob rather than in the
// javascript heap.
const compressed = await new Response(
summed.pipeThrough(new CompressionStream('deflate-raw'))
).blob()
if (size > MAX_ENTRY_SIZE) {
throw new Error(`Save file "${name}" is too large for a zip`)
}
const entry: WrittenEntry = {
nameBytes,
crc,
size,
compressedSize: compressed.size,
localHeaderOffset: offset,
}
entries.push(entry)
const header = localHeader(entry)
parts.push(header, compressed)
offset += header.length + compressed.size
},
async finish(): Promise<Blob> {
const directory = entries.map(centralHeader)
const directorySize = directory.reduce(
(total, header) => total + header.length,
0
)
return new Blob(
[
...parts,
...directory,
endOfCentralDirectory(entries, offset, directorySize),
],
{ type: 'application/zip' }
)
},
}
}