diff --git a/.gitignore b/.gitignore
index 52111e0..a344cb7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,6 @@ next-env.d.ts
# ai agent settings
.claude
+
+# scratch directory for temporary files, deleted once a task is done
+/TEMP
diff --git a/AGENTS.md b/AGENTS.md
index c941097..9de2d86 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,7 +18,7 @@ Source files live in `src/`:
- `app/` for routes,
- `components/` for shared UI
- `services/` for backend calls
-- `lib/` for helpers
+- `lib/` for helpers, with save compression in `lib/saveArchive/`
- `config/` for app config
- `types/` for .d.ts type declaration files
@@ -38,8 +38,8 @@ SaveParserBackendService -> /api/proxy/saveParserBackend/* -> backend
- [src/services/SaveParserBackendService.ts](src/services/SaveParserBackendService.ts)
is the only place that talks to the backend. It exposes `uploadSave`,
- `getSaveStatus` and `getParsedSave` — upload, poll until parsing finishes,
- then fetch the result.
+ `getSaveStatus`, `getParsedData` and `getSaveFile` — upload, poll until
+ parsing finishes, then fetch the result or the stored save back.
- [src/app/api/proxy/saveParserBackend/[[...path]]/route.ts](src/app/api/proxy/saveParserBackend/[[...path]]/route.ts)
forwards every method to the configured backend URL. It exists only to keep
requests same-origin, and is meant to be deleted once the backend handles
@@ -47,25 +47,116 @@ SaveParserBackendService -> /api/proxy/saveParserBackend/* -> backend
- Route paths live in [src/app/api/proxy/paths.ts](src/app/api/proxy/paths.ts)
rather than as string literals, so both sides of the proxy stay in sync.
+### Save files on the wire
+
+Saves are **not** sent as the game wrote them. The backend runs on a cheap two
+core server with little disk space, so the browser re-compresses every save
+before uploading it:
+
+```
+plain text save -> tar { "gamestate" } -> zstd -> upload
+archived save -> tar { "meta", "gamestate", ... } -> zstd -> upload
+```
+
+Uploads are `POST /uploadSave?game=…&originalFileType=text|zip&originalFileName=…`
+with `Content-Type: application/zstd`. The browser cannot stream a request
+body outside Chrome, so the compressed result is sent as one blob; responses
+stream everywhere, so downloads need nothing special. The tar always holds the
+files the game itself would have in its archive, so the backend reads the game
+state from the `gamestate` entry either way, and `getSaveStatus` returns `originalFileType`
+and `originalFileName` so a download can rebuild the right shape.
+
+The pieces all live in [src/lib/saveArchive/](src/lib/saveArchive/):
+[gameZip.ts](src/lib/saveArchive/gameZip.ts) (the game's own zip format,
+written here on the browser's `CompressionStream('deflate-raw')`),
+[zstdStream.ts](src/lib/saveArchive/zstdStream.ts) (compression, on
+[@tootallnate/zstd-wasm](https://github.com/TooTallNate/switch-tools/tree/main/packages/zstd-wasm)),
+[archive.ts](src/lib/saveArchive/archive.ts) (pack and rebuild, with the tar
+from [modern-tar](https://github.com/ayuhito/modern-tar)),
+[messages.ts](src/lib/saveArchive/messages.ts) (the worker protocol), and
+[worker.ts](src/lib/saveArchive/worker.ts) with
+[client.ts](src/lib/saveArchive/client.ts) (the worker and its main thread
+side).
+
+**The whole pipeline is streams.** A save is never held in memory as a whole:
+the file is read, unzipped, tarred and compressed as it flows, and only the
+finished result becomes a `Blob`, which is browser managed storage rather than
+a heap allocation. A real 11 MB EU4 save (84 MB of `gamestate`) compresses to
+about 4.15 MB in two seconds. Keep it that way — an `arrayBuffer()` call on a
+save is a bug.
+
+Three things there are easy to break:
+
+- **The zip headers are not decoration.** The games reject archives with NTFS
+ timestamps or unexpected header fields, so every field matches a real save:
+ deflate, zeroed DOS date, MS-DOS origin, version made by 63, version needed
+ 20, no extra field, no data descriptor. That last pair is why no zip library
+ is used: they all build the DOS date from a `Date` and clamp it to 1980, and
+ the streaming writers add a data descriptor and a zip64 extra field to every
+ entry. Writing the headers here is about a hundred lines, because deflate
+ itself comes from the browser. One consequence to keep: each entry is
+ deflated into a blob before its header is written, since the sizes and the
+ CRC go in front of the data — the _compressed_ bytes are buffered, never the
+ save.
+- **The zstd wasm is a runtime asset, not part of the bundle.**
+ `@tootallnate/zstd-wasm` deliberately loads no files of its own, so
+ `zstdStream.ts` points `new URL(…, import.meta.url)` at
+ `node_modules/@tootallnate/zstd-wasm/dist/zstd.wasm` and Turbopack emits it
+ as a hashed asset the worker fetches. That path is relative to
+ `src/lib/saveArchive/zstdStream.ts`, so moving the file means fixing it.
+- **Three zstd libraries were tried and rejected before this one**, so do not
+ swap it lightly. `@oneidentity/zstd-js` compresses whole buffers and leaves a
+ frame unterminated whenever the payload length is an exact multiple of
+ 131072, `zstd-codec`'s emscripten build never returns control to the event
+ loop, and `fzstd` is the unreadable single-file kind of code this repository
+ does not depend on.
+- **Compression runs in a Web Worker** because it would otherwise block the
+ page for seconds on a large save.
+
+[COMPRESSION.md](COMPRESSION.md) has every zstd level measured on a real save —
+size, time and the memory the encoder and decoder need. Consult it instead of
+measuring again when a device cannot cope with the current level.
+
Configuration comes from [src/config/AppConfig.ts](src/config/AppConfig.ts)
(defaults) merged with `runtimeConfig.json` at the repo root, which holds the
-backend URL. That file is gitignored, so a fresh clone starts without one and
-falls back to the defaults.
+backend URL and the save upload limits. That file is gitignored, so a fresh
+clone starts without one and falls back to the defaults.
+
+`AppConfig` is server side only, so values the browser needs are passed down as
+props: [src/app/page.tsx](src/app/page.tsx) reads `appConfig.saveUpload` and
+hands it to the upload dialog.
### Current status
Much of the app is still scaffold. Do not assume a feature works because the UI
for it exists:
-- **The grid is not wired up.** [src/app/browse/page.tsx](src/app/browse/page.tsx)
- renders two hardcoded rows and never calls the backend. Nothing connects the
- upload flow to it yet.
+- **The grid is not wired up.** [src/components/BrowseSaveDataGrid.tsx](src/components/BrowseSaveDataGrid.tsx)
+ renders two hardcoded rows and never calls the backend. `/browse?id=…` only
+ uses the id for the download button so far.
+- **The backend does not speak the new upload format yet.** It has to accept the
+ zstd compressed tar described above and serve `getSaveFile`; until it does,
+ uploads fail against the running service.
- **Auth does not work.** `AuthService` posts to `/api/auth/login` and
`/api/auth/register`, which do not exist as routes. The service is slated for
removal in favour of Discord OAuth.
- **`hashPassword` is a no-op** that returns the password unchanged. Treat any
current auth code as a placeholder, not as something to build on.
+## Tools and files
+
+**Prefer the editor's own tools over shell commands that do the same thing.**
+Reading, editing, writing and searching files have dedicated tools; reaching
+for `cat`, `sed`, `grep` or a throwaway script instead does the same work while
+spending more tokens, and every command may need its own approval. Use the shell
+for what only the shell can do — running the build, the linter, git, a real
+program.
+
+**Work inside the project directory.** For temporary files — scratch scripts,
+intermediate output, test data — create a `TEMP/` directory in the project root
+and keep everything there, then delete that directory once the work is done.
+Ask before reading or writing anything outside the project directory.
+
## Terminal
**On Windows, prefer `bash.exe`** — but only a real MSYS2, Cygwin, or
@@ -134,11 +225,6 @@ editing, or match the surrounding style exactly.
Import from within `src/` using the `@/` alias, for example
`import { getMsgFromError } from '@/lib/errors'`.
-Write doc comments as JSDoc (`/** ... */`), which editors show on hover.
-Plain `//` and `///` comments do not appear in tooltips.
-In `.ts` files put only prose in the JSDoc and let the signature carry the types;
-do not duplicate types in `{braces}`.
-
Catch blocks should not annotate the error. Let it be `unknown` and convert it
with the shared helper:
@@ -151,6 +237,56 @@ with the shared helper:
Avoid `any`; `no-explicit-any` is enforced. Prefer `unknown` plus narrowing.
+**Comment the code you write.** This codebase is read by human programmers who
+did not write it, and a wall of uncommented agent-written code is expensive to
+review. Keep comments simple and preferably single line, placed above the code
+they describe, and write them in plain language rather than in terms of the
+types. Say _why_ the code does what it does — what is non-obvious, what would
+break if it were written the obvious way — and leave out what the code already
+states plainly. A tricky constant, a workaround for someone else's bug, or an
+ordering that matters always deserves a line.
+
+Write doc comments as JSDoc (`/** ... */`), which editors show on hover.
+Plain `//` and `///` comments do not appear in tooltips.
+In `.ts` files put only prose in the JSDoc and let the signature carry the types;
+do not duplicate types in `{braces}`.
+
+## Dependencies
+
+**Search for an existing library before writing your own implementation.**
+Formats, protocols and codecs are full of details that only show up against
+real data, and a maintained library has already met them. This project once
+carried a hand-written tar reader/writer and zip writer; both were deleted in
+favour of libraries.
+
+**Read the library's source before adding it, and judge it on that.** Download
+counts say nothing about quality. Even a popular package may turn out to be a
+single ten thousand line file whose variables and functions are all named `a`,
+`b` and `c`, with no comments anywhere. Such code cannot be read, cannot be
+maintained, and is very likely to be carrying bugs nobody can find — do not
+depend on it. Look for readable sources, comments that explain intent, and an
+API that fits the way the code here is written.
+
+If nothing of good quality exists, say so and propose writing an implementation
+here instead, rather than pulling in the least bad option.
+
+Do not add a second library for a job a package already in `package.json` can
+do. A thin wrapper that shapes an installed library's API to what the code
+needs is better than a new dependency. But if the API of the library
+doesn't fit the project, the wrapper can be bigger than your own implementation
+of the algorithm.
+
+## Checking browser support
+
+**Never use caniuse.com.** Its data is wrong and incomplete, missing features
+that shipped five or more years ago, and a decision made from it will be wrong.
+
+Use MDN's [browser-compat-data](https://github.com/mdn/browser-compat-data),
+which is the source behind the MDN compatibility tables, and
+[webstatus.dev](https://webstatus.dev) for the baseline status of a feature. If
+those disagree or say nothing, the browsers' own release notes and bug trackers
+settle it.
+
## Things that will bite you
**Both TypeScript 6 and 7 are installed on purpose, under swapped names.**
diff --git a/COMPRESSION.md b/COMPRESSION.md
new file mode 100644
index 0000000..e983a33
--- /dev/null
+++ b/COMPRESSION.md
@@ -0,0 +1,73 @@
+# Compression levels
+
+Reference for `saveUpload.zstdCompressionLevel` in
+[src/config/AppConfig.ts](src/config/AppConfig.ts). If compression ever turns
+out to be too slow or too memory hungry on some device, pick a different level
+from the table below rather than measuring again.
+
+The level only affects the uploader. Decompression on the parser backend costs
+the same at every level.
+
+## What was measured
+
+A real Europa Universalis IV save, as the game wrote it:
+
+| | |
+| ------------- | ----------------------------------------------------- |
+| File | `Ibevar1475_01_02save.eu4`, a zip archive |
+| Size on disk | **11,502,739 bytes** (11.0 MB) |
+| Entries | `gamestate` (87,188,760 bytes), `meta`, `ai` |
+| Input to zstd | **87,342,592 bytes** — the tar of those three entries |
+
+So the ratios below are against the original 11.5 MB game archive, and zstd
+itself is working on the 87 MB of uncompressed save text inside it.
+
+Each level ran through the project's own `packSave` in a separate process. The
+heap columns are the WebAssembly linear memory the encoder and the decoder grow
+to, read from the instance itself — that is the memory a browser tab spends,
+and it is what to look at when a device runs out. The machine was a desktop;
+**a phone is several times slower**, so treat the times as a lower bound.
+
+Every level round tripped to the same 87,342,592 byte tar, so each row is
+correct output rather than merely fast output.
+
+## The table
+
+| level | compressed | ratio | compress | decompress | encoder heap | decoder heap | window |
+| -----: | ------------: | --------: | ---------: | ---------: | -----------: | -----------: | -----: |
+| 1 | 6,698,281 | 1.72x | 0.54 s | 0.28 s | 8 MB | 8 MB | 1 MB |
+| 2 | 6,600,451 | 1.74x | 0.54 s | 0.29 s | 8 MB | 8 MB | 1 MB |
+| 3 | 6,094,116 | 1.89x | 0.57 s | 0.28 s | 8 MB | 8 MB | 2 MB |
+| 4 | 6,082,362 | 1.89x | 0.61 s | 0.28 s | 8 MB | 8 MB | 2 MB |
+| 5 | 5,280,090 | 2.18x | 0.92 s | 0.28 s | 8 MB | 8 MB | 2 MB |
+| 6 | 4,943,366 | 2.33x | 1.13 s | 0.27 s | 8 MB | 8 MB | 2 MB |
+| 7 | 4,738,515 | 2.43x | 1.27 s | 0.27 s | 9 MB | 8 MB | 2 MB |
+| 8 | 4,514,853 | 2.55x | 1.52 s | 0.28 s | 9 MB | 8 MB | 2 MB |
+| 9 | 4,445,368 | 2.59x | 1.51 s | 0.26 s | 16 MB | 8 MB | 4 MB |
+| 10 | 4,280,567 | 2.69x | 1.74 s | 0.27 s | 26 MB | 8 MB | 4 MB |
+| **11** | **4,157,684** | **2.77x** | **2.44 s** | **0.32 s** | **26 MB** | **8 MB** | 4 MB |
+| 12 | 4,155,668 | 2.77x | 2.39 s | 0.27 s | 46 MB | 8 MB | 4 MB |
+| 13 | 4,257,141 | 2.70x | 3.76 s | 0.27 s | 38 MB | 8 MB | 4 MB |
+| 14 | 4,108,558 | 2.80x | 5.39 s | 0.28 s | 54 MB | 8 MB | 4 MB |
+| 15 | 3,968,262 | 2.90x | 8.13 s | 0.33 s | 70 MB | 8 MB | 4 MB |
+| 16 | 3,862,217 | 2.98x | 11.63 s | 0.26 s | 38 MB | 8 MB | 4 MB |
+| 17 | 3,733,198 | 3.08x | 12.39 s | 0.26 s | 58 MB | 10 MB | 8 MB |
+| 18 | 3,705,141 | 3.10x | 15.11 s | 0.26 s | 59 MB | 10 MB | 8 MB |
+| 19 | 3,449,475 | 3.33x | 27.78 s | 0.28 s | 91 MB | 10 MB | 8 MB |
+| 20 | 3,344,642 | 3.44x | 28.24 s | 0.27 s | 195 MB | 34 MB | 32 MB |
+| 21 | 3,285,836 | 3.50x | 33.91 s | 0.30 s | 387 MB | 66 MB | 64 MB |
+| 22 | 3,245,980 | 3.54x | 49.02 s | 0.30 s | 835 MB | 130 MB | 128 MB |
+
+## Picking a level
+
+**11 is the default**, because it is where the curve flattens. It gives good compression in reasonable time.
+
+If there will be troubles with this level in the future, just change it in config.
+
+## Reproducing
+
+There is no script in the repository for this; it was a one-off measurement.
+To repeat it, stream a save through `packSave` once per level in a separate
+process, and read `memory.buffer.byteLength` off the encoder and decoder
+instances of [@tootallnate/zstd-wasm](https://www.npmjs.com/package/@tootallnate/zstd-wasm)
+for the heap figures.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index c0cc6ef..27732a0 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -3,7 +3,7 @@ import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
import nextTypescript from 'eslint-config-next/typescript'
export default defineConfig([
- globalIgnores(['.next/**', 'node_modules/**', 'next-env.d.ts']),
+ globalIgnores(['.next/**', 'node_modules/**', 'TEMP/**', 'next-env.d.ts']),
nextCoreWebVitals,
nextTypescript,
])
diff --git a/package-lock.json b/package-lock.json
index 475a0d3..e9d4c5c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
"name": "paradox-save-parser-frontend",
"version": "0.1.0",
"dependencies": {
+ "@tootallnate/zstd-wasm": "^0.1",
"ag-grid-community": "^36",
"ag-grid-react": "^36",
"bootstrap": "^5",
+ "modern-tar": "^0.8",
"next": "^16",
"react": "^19",
"react-dom": "^19"
@@ -20,7 +22,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript/native": "npm:typescript@^7.0",
- "cross-env": "^10.1.0",
+ "cross-env": "^10",
"eslint": "^9",
"eslint-config-next": "^16",
"prettier": "^3",
@@ -1402,6 +1404,12 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tootallnate/zstd-wasm": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/zstd-wasm/-/zstd-wasm-0.1.0.tgz",
+ "integrity": "sha512-cIJiBKLTWjIONc2KDlQg9YjBxg99eXLmseWZumZibDb+TAZxWH4btuu4mK4XpNlCAXlkuZsQjebYfIypriDgLw==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@@ -3260,9 +3268,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.434",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.434.tgz",
- "integrity": "sha512-7EeFW9OLf1NN9NKQP9xdOE/XKqjxv7JDPP6AEAkHli11+Fae1OaLoNuAf13hQv1PPNtuES0pLQSPk2+fS3s8ww==",
+ "version": "1.5.438",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz",
+ "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==",
"dev": true,
"license": "ISC"
},
@@ -5097,6 +5105,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/modern-tar": {
+ "version": "0.8.5",
+ "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
+ "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5218,9 +5235,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.56",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
- "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
+ "version": "2.0.57",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5526,9 +5543,9 @@
}
},
"node_modules/prettier": {
- "version": "3.9.8",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
- "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
+ "version": "3.9.9",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
+ "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
"license": "MIT",
"bin": {
diff --git a/package.json b/package.json
index 1f7d324..96ce5ad 100644
--- a/package.json
+++ b/package.json
@@ -11,19 +11,21 @@
"format": "npx prettier --write ."
},
"dependencies": {
- "ag-grid-community": "^36",
- "ag-grid-react": "^36",
- "bootstrap": "^5",
"next": "^16",
"react": "^19",
- "react-dom": "^19"
+ "react-dom": "^19",
+ "bootstrap": "^5",
+ "ag-grid-community": "^36",
+ "ag-grid-react": "^36",
+ "@tootallnate/zstd-wasm": "^0.1",
+ "modern-tar": "^0.8"
},
"devDependencies": {
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript/native": "npm:typescript@^7.0",
- "cross-env": "^10.1.0",
+ "cross-env": "^10",
"eslint": "^9",
"eslint-config-next": "^16",
"prettier": "^3",
diff --git a/src/app/browse/page.tsx b/src/app/browse/page.tsx
index 78a1a05..de02e82 100644
--- a/src/app/browse/page.tsx
+++ b/src/app/browse/page.tsx
@@ -1,75 +1,29 @@
-'use client'
-
-import { AgGridReact } from 'ag-grid-react'
-import {
- AllCommunityModule,
- themeQuartz,
- colorSchemeDark,
-} from 'ag-grid-community'
-import { useSyncExternalStore } from 'react'
-
-const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
-
-// add the vertical lines between cells
-const BASE_THEME = themeQuartz.withParams({
- columnBorder: true,
- headerColumnBorder: true,
- headerCellBackgroundTransitionDuration: 0,
-})
-// Built once, so the grid keeps seeing the same theme object between renders.
-const LIGHT_THEME = BASE_THEME
-const DARK_THEME = BASE_THEME.withPart(colorSchemeDark)
-
-// Notifies React whenever the browser's light/dark color scheme changes.
-function subscribeToColorScheme(onChange: () => void) {
- const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
- mediaQuery.addEventListener('change', onChange)
- return () => mediaQuery.removeEventListener('change', onChange)
-}
-
-function isDarkSchemePreferred() {
- return window.matchMedia(DARK_SCHEME_QUERY).matches
-}
-
-// The server has no color scheme, so it always prerenders the light theme.
-function isDarkSchemePreferredOnServer() {
- return false
-}
+import BrowseSaveDataGrid from '@/components/BrowseSaveDataGrid'
+import ConditionalView from '@/components/ConditionalView'
+import SaveDownloadButton from '@/components/SaveDownloadButton'
// TODO: create tabs with general info, tables, plots
+// TODO: feed the grid with the parsed save the id points at
+
+interface BrowseSaveDataPageProps {
+ searchParams: Promise<{ id?: string }>
+}
// Page that shows a save file's parsed data in a grid.
-export default function BrowseSaveDataPage() {
- // Read the color scheme straight from the browser instead of mirroring it
- // into state, which would need an extra render to catch up.
- const prefersDark = useSyncExternalStore(
- subscribeToColorScheme,
- isDarkSchemePreferred,
- isDarkSchemePreferredOnServer
- )
- const agTheme = prefersDark ? DARK_THEME : LIGHT_THEME
+export default async function BrowseSaveDataPage(
+ props: BrowseSaveDataPageProps
+) {
+ const { id } = await props.searchParams
return (
-
-
+
+
+
+
+
+
+
+
)
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 091f406..2261daf 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,11 +1,14 @@
import SaveFileUploadingDialog from '@/components/SaveFileUploadingDialog'
+import { appConfig } from '@/lib/configLoader'
export default function HomePage() {
return (
Welcome to Paradox Save Parser!
-
+ {/* The config is read on the server, so the limits are handed to
+ the dialog instead of being fetched by it. */}
+
)
}
diff --git a/src/components/BrowseSaveDataGrid.tsx b/src/components/BrowseSaveDataGrid.tsx
new file mode 100644
index 0000000..5a99a9d
--- /dev/null
+++ b/src/components/BrowseSaveDataGrid.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import { AgGridReact } from 'ag-grid-react'
+import {
+ AllCommunityModule,
+ themeQuartz,
+ colorSchemeDark,
+} from 'ag-grid-community'
+import { useSyncExternalStore } from 'react'
+
+const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
+
+// add the vertical lines between cells
+// Built once, so the grid keeps seeing the same theme object between renders.
+const LIGHT_THEME = themeQuartz.withParams({
+ columnBorder: true,
+ headerColumnBorder: true,
+ headerCellBackgroundTransitionDuration: 0,
+})
+const DARK_THEME = LIGHT_THEME.withPart(colorSchemeDark)
+
+// Notifies React whenever the browser's light/dark color scheme changes.
+function subscribeToColorScheme(onChange: () => void) {
+ const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
+ mediaQuery.addEventListener('change', onChange)
+ return () => mediaQuery.removeEventListener('change', onChange)
+}
+
+function isDarkSchemePreferred() {
+ return window.matchMedia(DARK_SCHEME_QUERY).matches
+}
+
+// The server has no color scheme, so it always prerenders the light theme.
+function isDarkSchemePreferredOnServer() {
+ return false
+}
+
+// Grid showing a save file's parsed data.
+export default function BrowseSaveDataGrid() {
+ // Read the color scheme straight from the browser instead of mirroring it
+ // into state, which would need an extra render to catch up.
+ const prefersDark = useSyncExternalStore(
+ subscribeToColorScheme,
+ isDarkSchemePreferred,
+ isDarkSchemePreferredOnServer
+ )
+ const agTheme = prefersDark ? DARK_THEME : LIGHT_THEME
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/ConditionalView.tsx b/src/components/ConditionalView.tsx
new file mode 100644
index 0000000..8e50db0
--- /dev/null
+++ b/src/components/ConditionalView.tsx
@@ -0,0 +1,13 @@
+interface ConditionalViewProps {
+ cond: unknown
+ children: React.ReactNode
+}
+
+// Shows its children only when `cond` is truthy, and nothing otherwise.
+// Unlike `cond && `, a falsy value here never leaks into the page: JSX
+// renders 0 and '' as text, so an empty string or a count of zero would show up.
+export default function ConditionalView(props: ConditionalViewProps) {
+ if (!props.cond) return null
+
+ return <>{props.children}>
+}
diff --git a/src/components/SaveDownloadButton.tsx b/src/components/SaveDownloadButton.tsx
new file mode 100644
index 0000000..683bca7
--- /dev/null
+++ b/src/components/SaveDownloadButton.tsx
@@ -0,0 +1,73 @@
+'use client'
+
+import { useState } from 'react'
+import ConditionalView from './ConditionalView'
+import FormError from './FormError'
+import { downloadBlobAsFile } from '@/lib/downloadFile'
+import { getMsgFromError } from '@/lib/errors'
+import { rebuildOriginalSave } from '@/lib/saveArchive/client'
+import { SAVE_PROGRESS_STAGE_LABELS } from '@/lib/saveArchive/messages'
+import {
+ saveParserBackendService,
+ SaveStatusResponse,
+} from '@/services/SaveParserBackendService'
+
+interface SaveDownloadButtonProps {
+ saveId: string
+ // Status of the save, when the caller already has it. It is fetched on
+ // demand otherwise, since the download needs the shape the save was
+ // uploaded in and its file name.
+ status?: SaveStatusResponse | null
+}
+
+// Downloads a stored save and rebuilds the file the game originally wrote.
+export default function SaveDownloadButton(props: SaveDownloadButtonProps) {
+ const [error, setError] = useState(null)
+ const [progress, setProgress] = useState(null)
+
+ const handleDownload = async () => {
+ setError(null)
+ try {
+ const status =
+ props.status ??
+ (await saveParserBackendService.getSaveStatus(props.saveId))
+
+ setProgress(SAVE_PROGRESS_STAGE_LABELS.downloading)
+ const archive = await saveParserBackendService.getSaveFile(
+ props.saveId
+ )
+
+ const saveFile = await rebuildOriginalSave(
+ archive,
+ status.originalFileType,
+ (stage) => setProgress(SAVE_PROGRESS_STAGE_LABELS[stage])
+ )
+ downloadBlobAsFile(saveFile, status.originalFileName)
+ } catch (err) {
+ const errMsg = getMsgFromError(err)
+ console.error('Error while downloading save:', errMsg)
+ setError(errMsg)
+ } finally {
+ setProgress(null)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
{progress}
+
+
+ )
+}
diff --git a/src/components/SaveFileUploadingDialog.tsx b/src/components/SaveFileUploadingDialog.tsx
index 1456027..dca0da1 100644
--- a/src/components/SaveFileUploadingDialog.tsx
+++ b/src/components/SaveFileUploadingDialog.tsx
@@ -2,30 +2,34 @@
import {
saveParserBackendService,
- SaveStatusResponse,
UploadSaveResponse,
} from '@/services/SaveParserBackendService'
+import Link from 'next/link'
import { useState } from 'react'
-import FormError from './FormError'
+import ConditionalView from './ConditionalView'
+import type { SaveUploadConfig } from '@/config/AppConfig'
+import { GameEnum } from '@/config/games'
import { getMsgFromError } from '@/lib/errors'
+import { SAVE_PROGRESS_STAGE_LABELS } from '@/lib/saveArchive/messages'
-export enum GameEnum {
- EU4 = 'eu4',
- Stellaris = 'stellaris',
+interface SaveFileUploadingDialogProps {
+ // Comes from the server, where runtimeConfig.json is read.
+ saveUploadConfig: SaveUploadConfig
}
-//TODO: move to separate page
-//TODO: add redirect to ?id={id} to see status of long parsing save
+const IN_BROWSER = '[In Browser]'
+const ON_SERVER = '[On Server]'
// Dialog for picking a game and save file, uploading it, and polling the parse status until done.
-export default function SaveFileUploadingDialog() {
- const [error, setError] = useState(null)
+export default function SaveFileUploadingDialog(
+ props: SaveFileUploadingDialogProps
+) {
const [selectedFile, setSelectedFile] = useState(null)
const [game, setGame] = useState(GameEnum.EU4)
const [uploadDisabled, setUploadDisabled] = useState(false)
- const [saveStatus, setSaveStatus] = useState(
- null
- )
+ // This is what user will see
+ const [statusMessage, setStatusMessage] = useState(null)
+
const [uploadResult, setUploadResult] = useState(
null
)
@@ -42,10 +46,10 @@ export default function SaveFileUploadingDialog() {
}
}
- // Uploads the selected save file, then repeatedly polls its status until parsing is done or an error occurs.
+ // Compresses and uploads the selected save file, then repeatedly polls its status until parsing is done or an error occurs.
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault()
- setError(null)
+ setStatusMessage(null)
setUploadDisabled(true)
try {
if (!selectedFile) return
@@ -53,10 +57,17 @@ export default function SaveFileUploadingDialog() {
console.log('Uploading file:', selectedFile.name)
const uploadResponse = await saveParserBackendService.uploadSave(
game,
- selectedFile
+ selectedFile,
+ props.saveUploadConfig,
+ (stage) =>
+ setStatusMessage(
+ `${IN_BROWSER} ${SAVE_PROGRESS_STAGE_LABELS[stage]}`
+ )
)
console.log('Upload response:', uploadResponse)
- setUploadResult(uploadResponse)
+ setStatusMessage(
+ `${ON_SERVER} Waiting for the save to be parsed...`
+ )
const pollInterval = 1000 // ms
// Poll the backend every second for the parsing status until it reports "done".
@@ -67,23 +78,25 @@ export default function SaveFileUploadingDialog() {
uploadResponse.id
)
console.log('Save status:', statusResponse.status)
- setSaveStatus(statusResponse)
+ setStatusMessage(`${ON_SERVER} ${statusResponse.status}`)
if (statusResponse.status.toLowerCase() === 'done') {
clearInterval(intervalId)
+ // Only a parsed save is worth showing or browsing.
+ setUploadResult(uploadResponse)
setUploadDisabled(false)
}
} catch (err) {
const errMsg = getMsgFromError(err)
console.error('Error while polling status:', errMsg)
clearInterval(intervalId)
- setError(errMsg)
+ setStatusMessage(`${ON_SERVER} ${errMsg}`)
setUploadDisabled(false)
}
}, pollInterval)
} catch (err) {
const errMsg = getMsgFromError(err)
console.error(errMsg)
- setError(errMsg)
+ setStatusMessage(`${IN_BROWSER} ${errMsg}`)
setUploadDisabled(false)
}
}
@@ -97,8 +110,6 @@ export default function SaveFileUploadingDialog() {
return (
-
-
- {/* TODO: extract this to a component
- */}
- {uploadResult && (
-
- Save Id: {uploadResult.id}
-
- )}
+
+
{statusMessage}
+
- {saveStatus && (
+
- Status: {saveStatus.status}
+ Save Id: {uploadResult?.id}
- )}
-
- {/* TODO: add link to /browse?id=${saveStatus.id} */}
+
+ Browse parsed save
+
+
)
diff --git a/src/config/AppConfig.ts b/src/config/AppConfig.ts
index 94f6b31..91c36c8 100644
--- a/src/config/AppConfig.ts
+++ b/src/config/AppConfig.ts
@@ -5,9 +5,19 @@ export const defaultConfig = {
saveParserBackend: {
url: 'http://127.0.0.1:5226',
},
- // TODO: implement auth api
- auth: {},
+ },
+ saveUpload: {
+ // Size of a save if it is uploaded as uncompressed. The browser packs
+ // saves as a stream, so these limits are about the backend's disk
+ // rather than about memory.
+ maxSizePlainTextBytes: 200 * 1024 * 1024,
+ // Archives produced by the game, recognized by the zip magic bytes.
+ maxSizeCompressedBytes: 60 * 1024 * 1024,
+ // read COMPRESSION.md
+ zstdCompressionLevel: 11,
},
}
export type AppConfig = typeof defaultConfig
+
+export type SaveUploadConfig = AppConfig['saveUpload']
diff --git a/src/config/games.ts b/src/config/games.ts
new file mode 100644
index 0000000..9b187c5
--- /dev/null
+++ b/src/config/games.ts
@@ -0,0 +1,20 @@
+// Games the parser backend can handle. The values go into the upload request.
+//TODO: add more games
+export enum GameEnum {
+ EU4 = 'eu4',
+ Stellaris = 'stellaris',
+}
+
+/**
+ * Name the game state gets inside a save archive, per game.
+ *
+ * A save produced by the game is either a zip archive holding this file (plus
+ * companion files like `meta`), or the very same content as plain text. When a
+ * plain text save is uploaded, this is the name it is stored under, so the
+ * backend always reads the 'gamestate' or whatever is set for the specific game.
+ * The parser backend keeps the same map on its side.
+ */
+export const GAME_SAVE_ENTRY_NAMES: Record = {
+ [GameEnum.EU4]: 'gamestate',
+ [GameEnum.Stellaris]: 'gamestate',
+}
diff --git a/src/lib/downloadFile.ts b/src/lib/downloadFile.ts
new file mode 100644
index 0000000..b26dd26
--- /dev/null
+++ b/src/lib/downloadFile.ts
@@ -0,0 +1,17 @@
+// Revoking the object url right after the click can cancel the download in
+// some browsers, so it is kept alive for a while first.
+const URL_LIFETIME_MS = 20_000
+
+/**
+ * Makes the browser save a blob to disk under the given name.
+ */
+export function downloadBlobAsFile(blob: Blob, fileName: string) {
+ const url = URL.createObjectURL(blob)
+
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ link.click()
+
+ setTimeout(() => URL.revokeObjectURL(url), URL_LIFETIME_MS)
+}
diff --git a/src/lib/formatBytes.ts b/src/lib/formatBytes.ts
new file mode 100644
index 0000000..6f565bd
--- /dev/null
+++ b/src/lib/formatBytes.ts
@@ -0,0 +1,11 @@
+const KILOBYTE = 1024
+const MEGABYTE = 1024 * KILOBYTE
+
+/**
+ * Formats a byte count for error messages and status lines, e.g. "12.3 MB".
+ */
+export function formatBytes(bytes: number): string {
+ if (bytes >= MEGABYTE) return `${(bytes / MEGABYTE).toFixed(1)} MB`
+ if (bytes >= KILOBYTE) return `${(bytes / KILOBYTE).toFixed(1)} KB`
+ return `${bytes} B`
+}
diff --git a/src/lib/saveArchive/archive.ts b/src/lib/saveArchive/archive.ts
new file mode 100644
index 0000000..528131e
--- /dev/null
+++ b/src/lib/saveArchive/archive.ts
@@ -0,0 +1,138 @@
+// Converting between the save files a game reads and the archive the parser
+// backend stores. The tar always holds the files the game itself would have in
+// its archive, so the backend reads the game state from the same entry either
+// way, and a download rebuilds whichever shape was uploaded:
+//
+// plain text save -> tar { "gamestate" } -> zstd
+// archived save -> tar { "meta", "gamestate", ... } -> zstd
+//
+// Everything runs as a stream: a save is never held in memory as a whole.
+
+import { createTarDecoder, createTarPacker } from 'modern-tar'
+import { createGameZipBuilder, readGameZip } from './gameZip'
+import { OriginalFileType, SaveProgressStage } from './messages'
+import {
+ createZstdCompressStream,
+ createZstdDecompressStream,
+} from './zstdStream'
+
+type StageCallback = (stage: SaveProgressStage) => void
+
+// A fixed timestamp keeps uploads of the same save byte for byte identical.
+const TAR_ENTRY_MTIME = new Date(0)
+
+export interface PackSaveOptions {
+ /** Whether the file is an archive produced by the game. */
+ isZip: boolean
+ /** Name of the single entry used for a plain text save. */
+ entryName: string
+ compressionLevel: number
+}
+
+// Collects a stream into a blob, whose storage the browser manages. The blob
+// takes its type from the response's Content-Type.
+function collectBlob(
+ stream: ReadableStream,
+ type: string
+): Promise {
+ return new Response(stream, { headers: { 'Content-Type': type } }).blob()
+}
+
+// Writes the files of the save into the tar, in the order the game stored
+// them. Runs alongside the compression, which consumes the tar as it is built.
+async function packEntries(
+ file: Blob,
+ options: PackSaveOptions,
+ controller: ReturnType['controller']
+) {
+ try {
+ if (options.isZip) {
+ await readGameZip(file, (entry) =>
+ // Returns writable stream.
+ // Zip decoder decodes file entry stream and pipes it to the tar entry stream.
+ controller.add({
+ name: entry.name,
+ size: entry.size,
+ mtime: TAR_ENTRY_MTIME,
+ })
+ )
+ } else {
+ // Writes text file to tar entry
+ await file.stream().pipeTo(
+ controller.add({
+ name: options.entryName,
+ size: file.size,
+ mtime: TAR_ENTRY_MTIME,
+ })
+ )
+ }
+ } finally {
+ controller.finalize()
+ }
+}
+
+/**
+ * Compresses a save file the user picked into the archive that is uploaded.
+ */
+export async function packSave(
+ file: Blob,
+ options: PackSaveOptions,
+ onStage: StageCallback
+): Promise {
+ onStage('compressing')
+
+ const { readable, controller } = createTarPacker()
+ // The tar is written while it is being compressed, so neither side of the
+ // pipe ever holds the whole save.
+ const packed = packEntries(file, options, controller)
+ const compressed = collectBlob(
+ readable.pipeThrough(
+ createZstdCompressStream(options.compressionLevel)
+ ),
+ 'application/zstd'
+ )
+
+ const [, archive] = await Promise.all([packed, compressed])
+ return archive
+}
+
+/**
+ * Turns a stored archive back into a file the game can load.
+ */
+export async function rebuildSave(
+ archive: Blob,
+ originalFileType: OriginalFileType,
+ onStage: StageCallback
+): Promise {
+ onStage('decompressing')
+
+ const entries = archive
+ .stream()
+ .pipeThrough(createZstdDecompressStream())
+ .pipeThrough(createTarDecoder())
+ .getReader()
+
+ const zipBuilder =
+ originalFileType === 'zip' ? createGameZipBuilder() : null
+ let saveFile: Blob | null = null
+
+ for (;;) {
+ const { done, value: entry } = await entries.read()
+ if (done) break
+
+ if (zipBuilder) {
+ onStage('rebuilding')
+ await zipBuilder.add(entry.header.name, entry.body)
+ } else if (saveFile) {
+ // A plain text save is a single file, so anything after it would
+ // be silently dropped.
+ await entry.body.cancel()
+ } else {
+ saveFile = await collectBlob(entry.body, 'application/octet-stream')
+ }
+ }
+
+ if (zipBuilder) return await zipBuilder.finish()
+ if (!saveFile) throw new Error('Stored save archive is empty')
+ return saveFile
+}
diff --git a/src/lib/saveArchive/client.ts b/src/lib/saveArchive/client.ts
new file mode 100644
index 0000000..62ddb52
--- /dev/null
+++ b/src/lib/saveArchive/client.ts
@@ -0,0 +1,113 @@
+// Main thread side of the save archive worker: checks the file, starts the
+// worker and reports what it is doing.
+
+import type { SaveUploadConfig } from '@/config/AppConfig'
+import { GAME_SAVE_ENTRY_NAMES, GameEnum } from '@/config/games'
+import { formatBytes } from '@/lib/formatBytes'
+import { isZipFile } from './gameZip'
+import {
+ OriginalFileType,
+ SaveArchiveRequest,
+ SaveArchiveResponse,
+ SaveProgressStage,
+} from './messages'
+
+export type StageCallback = (stage: SaveProgressStage) => void
+
+export interface PackedSave {
+ data: Blob
+ originalFileType: OriginalFileType
+ originalFileName: string
+}
+
+// Runs a single request in a fresh worker, which is terminated once it answers.
+function runInWorker(
+ request: SaveArchiveRequest,
+ onStage: StageCallback
+): Promise {
+ return new Promise((resolve, reject) => {
+ const worker = new Worker(new URL('./worker.ts', import.meta.url), {
+ type: 'module',
+ })
+
+ worker.onmessage = (event: MessageEvent) => {
+ const message = event.data
+ if (message.kind === 'progress') {
+ onStage(message.stage)
+ return
+ }
+
+ worker.terminate()
+ if (message.kind === 'error') {
+ reject(new Error(message.message))
+ } else {
+ resolve(message.data)
+ }
+ }
+
+ worker.onerror = (event) => {
+ worker.terminate()
+ reject(new Error(event.message || 'Save file compression failed'))
+ }
+
+ worker.postMessage(request)
+ })
+}
+
+/**
+ * Compresses a save file the user picked into the archive that is uploaded.
+ *
+ * Plain text and archived saves are both accepted, are told apart by the zip
+ * magic bytes rather than by the file extension, and are checked against their
+ * own size limit.
+ */
+export async function packSaveForUpload(
+ game: GameEnum,
+ file: File,
+ config: SaveUploadConfig,
+ onStage: StageCallback
+): Promise {
+ const isZip = await isZipFile(file)
+ const sizeLimit = isZip
+ ? config.maxSizeCompressedBytes
+ : config.maxSizePlainTextBytes
+
+ if (file.size > sizeLimit) {
+ const saveKind = isZip ? 'archived' : 'plain text'
+ throw new Error(
+ `Save file is ${formatBytes(file.size)}, but the limit for ` +
+ `${saveKind} saves is ${formatBytes(sizeLimit)}`
+ )
+ }
+
+ const data = await runInWorker(
+ {
+ kind: 'pack',
+ file,
+ isZip,
+ entryName: GAME_SAVE_ENTRY_NAMES[game],
+ compressionLevel: config.zstdCompressionLevel,
+ },
+ onStage
+ )
+
+ return {
+ data,
+ originalFileType: isZip ? 'zip' : 'text',
+ originalFileName: file.name,
+ }
+}
+
+/**
+ * Rebuilds the save file as the game wrote it, out of a stored archive.
+ */
+export async function rebuildOriginalSave(
+ archive: Blob,
+ originalFileType: OriginalFileType,
+ onStage: StageCallback
+): Promise {
+ return await runInWorker(
+ { kind: 'rebuild', data: archive, originalFileType },
+ onStage
+ )
+}
diff --git a/src/lib/saveArchive/gameZip.ts b/src/lib/saveArchive/gameZip.ts
new file mode 100644
index 0000000..a4bea85
--- /dev/null
+++ b/src/lib/saveArchive/gameZip.ts
@@ -0,0 +1,313 @@
+// 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 {
+ 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 {
+ 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
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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) {
+ 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>({
+ transform(chunk, controller) {
+ crc = updateCrc(crc, chunk)
+ size += chunk.length
+ controller.enqueue(chunk as Uint8Array)
+ },
+ })
+ )
+
+ // 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 {
+ 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' }
+ )
+ },
+ }
+}
diff --git a/src/lib/saveArchive/messages.ts b/src/lib/saveArchive/messages.ts
new file mode 100644
index 0000000..031aa3c
--- /dev/null
+++ b/src/lib/saveArchive/messages.ts
@@ -0,0 +1,44 @@
+// Types shared between the save archive worker and the code that drives it.
+// This module deliberately imports nothing, so importing it never pulls the
+// compression code into a bundle.
+
+/** Shape the user originally uploaded, and the shape a download rebuilds. */
+export type OriginalFileType = 'text' | 'zip'
+
+/**
+ * Steps worth showing to the user, since each of them takes seconds on a big
+ * save. The worker reports the ones it runs itself; uploading and downloading
+ * are reported by the service around it.
+ */
+export type SaveProgressStage =
+ 'compressing' | 'uploading' | 'downloading' | 'decompressing' | 'rebuilding'
+
+export type SaveArchiveRequest =
+ | {
+ kind: 'pack'
+ file: File
+ isZip: boolean
+ entryName: string
+ compressionLevel: number
+ }
+ | {
+ kind: 'rebuild'
+ data: Blob
+ originalFileType: OriginalFileType
+ }
+
+export type SaveArchiveResponse =
+ // Blobs are handles to storage the browser manages, so passing one between
+ // threads copies nothing.
+ | { kind: 'progress'; stage: SaveProgressStage }
+ | { kind: 'done'; data: Blob }
+ | { kind: 'error'; message: string }
+
+/** Text shown while a stage is running. */
+export const SAVE_PROGRESS_STAGE_LABELS: Record = {
+ compressing: 'Compressing save file...',
+ uploading: 'Uploading save file...',
+ downloading: 'Downloading save file...',
+ decompressing: 'Decompressing save file...',
+ rebuilding: 'Rebuilding save file...',
+}
diff --git a/src/lib/saveArchive/worker.ts b/src/lib/saveArchive/worker.ts
new file mode 100644
index 0000000..921b818
--- /dev/null
+++ b/src/lib/saveArchive/worker.ts
@@ -0,0 +1,66 @@
+// Worker that packs and rebuilds save archives.
+//
+// Compressing a save is seconds of solid cpu work, so on the main thread it
+// would freeze the page long enough for the browser to offer to kill it.
+// Keeping the compression code here also keeps it out of every other bundle,
+// including the 534 kB zstd wasm.
+
+import { getMsgFromError } from '@/lib/errors'
+import { packSave, rebuildSave } from './archive'
+import {
+ SaveArchiveRequest,
+ SaveArchiveResponse,
+ SaveProgressStage,
+} from './messages'
+
+// The part of the worker global scope this file uses. It is declared here
+// because the project compiles against the dom types, where `self` is a window
+// whose postMessage takes a target origin; the worker types live in a lib that
+// cannot be loaded alongside them. Naming the message types also checks both
+// ends of the protocol, which DedicatedWorkerGlobalScope would leave as `any`.
+interface SaveArchiveWorkerScope {
+ postMessage(message: SaveArchiveResponse, transfer?: Transferable[]): void
+ addEventListener(
+ type: 'message',
+ listener: (event: MessageEvent) => void
+ ): void
+}
+
+const workerScope = self as unknown as SaveArchiveWorkerScope
+
+function reportStage(stage: SaveProgressStage) {
+ workerScope.postMessage({ kind: 'progress', stage })
+}
+
+// Runs one request and answers with its result or with the error it failed on.
+async function handleRequest(request: SaveArchiveRequest) {
+ try {
+ const result =
+ request.kind === 'pack'
+ ? await packSave(
+ request.file,
+ {
+ isZip: request.isZip,
+ entryName: request.entryName,
+ compressionLevel: request.compressionLevel,
+ },
+ reportStage
+ )
+ : await rebuildSave(
+ request.data,
+ request.originalFileType,
+ reportStage
+ )
+
+ workerScope.postMessage({ kind: 'done', data: result })
+ } catch (err) {
+ workerScope.postMessage({
+ kind: 'error',
+ message: getMsgFromError(err),
+ })
+ }
+}
+
+workerScope.addEventListener('message', (event) => {
+ void handleRequest(event.data)
+})
diff --git a/src/lib/saveArchive/zstdStream.ts b/src/lib/saveArchive/zstdStream.ts
new file mode 100644
index 0000000..efdf550
--- /dev/null
+++ b/src/lib/saveArchive/zstdStream.ts
@@ -0,0 +1,58 @@
+// Zstandard compression and decompression as stream transforms.
+//
+// Saves are re-compressed in the browser because the parser backend runs on a
+// cheap two core server with little disk space. zstd was picked over lzma2 and
+// brotli because its decompression speed does not depend on the level the data
+// was compressed with, so the level can be raised without costing the server
+// anything, and because its wasm build is by far the smallest of the three.
+//
+// @tootallnate/zstd-wasm does the work: it is upstream zstd 1.5.7 built with
+// wasi-sdk and driven through ZSTD_compressStream2, so it compresses a stream
+// as it arrives rather than a buffer at a time. A save of any size comes out
+// as one zstd frame, and memory stays at the library's own input and output
+// buffers.
+
+import {
+ ZstdCompressStream,
+ ZstdDecompressStream,
+} from '@tootallnate/zstd-wasm'
+
+// The wasm file ships inside the package. Turbopack rewrites this into the URL
+// it emits the asset at, so the 534 kB binary is fetched once at runtime and
+// cached by the browser instead of being inlined into the worker bundle.
+const wasmUrl = new URL(
+ '../../../node_modules/@tootallnate/zstd-wasm/dist/zstd.wasm',
+ import.meta.url
+)
+
+// Compiling takes a moment, so it happens once and both directions share it.
+let wasmModule: Promise | null = null
+
+function getWasm(): Promise {
+ // compileStreaming would need the server to send Content-Type:
+ // application/wasm, which is not worth depending on.
+ wasmModule ??= fetch(wasmUrl)
+ .then((res) => res.arrayBuffer())
+ .then((bytes) => WebAssembly.compile(bytes))
+ return wasmModule
+}
+
+/**
+ * Compresses a stream into a single zstd frame. The level goes from 1 to 22.
+ */
+export function createZstdCompressStream(
+ compressionLevel: number
+): TransformStream {
+ return new ZstdCompressStream(getWasm(), compressionLevel)
+}
+
+/**
+ * Decompresses a zstd stream. Frames that follow one another are read straight
+ * through, so an archive packed as several frames still opens.
+ */
+export function createZstdDecompressStream(): TransformStream<
+ Uint8Array,
+ Uint8Array
+> {
+ return new ZstdDecompressStream(getWasm())
+}
diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts
index 10f6e6a..b33d6dc 100644
--- a/src/services/AuthService.ts
+++ b/src/services/AuthService.ts
@@ -2,14 +2,12 @@
// already uses Discord, so email/password login is not worth building. The
// /api/auth/login and /api/auth/register routes below are never implemented —
// delete them along with this service.
-//TODO: controllable rate limits
class AuthService {
private API_BASE = '/api/auth'
// Hashes the password before sending it to the backend
private hashPassword(password: string) {
- // TODO: password hashing
return password
}
diff --git a/src/services/SaveParserBackendService.ts b/src/services/SaveParserBackendService.ts
index 2149da0..822dffa 100644
--- a/src/services/SaveParserBackendService.ts
+++ b/src/services/SaveParserBackendService.ts
@@ -1,4 +1,8 @@
import { PROXY_PATHS } from '@/app/api/proxy/paths'
+import type { SaveUploadConfig } from '@/config/AppConfig'
+import { GameEnum } from '@/config/games'
+import { packSaveForUpload, StageCallback } from '@/lib/saveArchive/client'
+import { OriginalFileType } from '@/lib/saveArchive/messages'
export interface UploadSaveResponse {
id: string
@@ -9,6 +13,10 @@ export interface SaveStatusResponse {
game: string
status: string
uploadDateTime: string
+ // Shape the save had when it was uploaded, needed to rebuild a file the
+ // game can load again.
+ originalFileType: OriginalFileType
+ originalFileName: string
}
// Tries to read the response body as string, returning an empty string if that fails.
@@ -23,16 +31,34 @@ async function getBodyTextOrEmpty(res: Response): Promise {
export class SaveParserBackendService {
private API_BASE = PROXY_PATHS.saveParserBackend
- //TODO: save size limit 30 mb
- // Uploads a save file for the given game and returns its assigned id.
- async uploadSave(game: string, file: File): Promise {
- const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
+ /**
+ * Re-compresses a save file and uploads it, returning its assigned id.
+ *
+ * Saves are not uploaded as the game wrote them. Plain text and archived
+ * saves are both packed into a tar inside a zstd frame, which is a lot
+ * smaller than the game's own zip and cheap for the backend to unpack.
+ * The shape the file originally had travels along, so it can be rebuilt
+ * when someone downloads the save later.
+ */
+ async uploadSave(
+ game: GameEnum,
+ file: File,
+ config: SaveUploadConfig,
+ onStage: StageCallback
+ ): Promise {
+ const save = await packSaveForUpload(game, file, config, onStage)
+ onStage('uploading')
+
+ const url =
+ `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}` +
+ `&originalFileType=${encodeURIComponent(save.originalFileType)}` +
+ `&originalFileName=${encodeURIComponent(save.originalFileName)}`
const response = await fetch(url, {
method: 'POST',
headers: {
- 'Content-Type': 'application/octet-stream',
+ 'Content-Type': 'application/zstd',
},
- body: file,
+ body: save.data,
})
if (!response.ok) {
@@ -61,8 +87,8 @@ export class SaveParserBackendService {
}
// Fetches the fully parsed data for a save once its parsing is done.
- async getParsedSave(id: string): Promise> {
- const url = `${this.API_BASE}/getParsedSave?id=${encodeURIComponent(id)}`
+ async getSaveData(id: string): Promise> {
+ const url = `${this.API_BASE}/getSaveData?id=${encodeURIComponent(id)}`
const response = await fetch(url)
if (!response.ok) {
@@ -74,6 +100,24 @@ export class SaveParserBackendService {
return await response.json()
}
+
+ /**
+ * Fetches the stored save archive, exactly as it was uploaded. Turning it
+ * back into a file the game can load is done in the browser.
+ */
+ async getSaveFile(id: string): Promise {
+ const url = `${this.API_BASE}/getSaveFile?id=${encodeURIComponent(id)}`
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to get save file: ${response.statusText} ` +
+ (await getBodyTextOrEmpty(response))
+ )
+ }
+
+ return await response.blob()
+ }
}
export const saveParserBackendService = new SaveParserBackendService()
diff --git a/tsconfig.json b/tsconfig.json
index 83e3baa..2ace016 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,5 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "TEMP"]
}