A cross-platform game collection tracker written in Rust. Memory Pak tracks consoles, games and toy-to-life collectibles (LEGO Dimensions, Skylanders and friends) as owned, favorite, wishlist and notes — across a desktop app, an offline web app, and a terminal.
Everything that is actually Memory Pak lives in one crate. A frontend is a thin adapter over it, and adding another one does not touch the others.
┌──────────────────────┐
│ memory_pak_core │ catalog · query engine
│ (no UI, no I/O) │ state · import/export
└──────────┬───────────┘
┌─────────────────┼─────────────────┐
memory_pak_tauri memory_pak_wasm memory_pak_tui
desktop + Android browser / PWA terminal
(Svelte) (Svelte) (ratatui)
memory_pak_core has no dependency on a UI toolkit, a window system or a
browser, and touches the filesystem only behind its optional fs feature. A
frontend does three things:
- load a
PersistedState(viastore::FileStore, or however its host prefers) and build aMemoryPakAppfrom it; - drive
query_consoles/query_games/query_collectiblesand render the views they return; - save
persisted_state()after a mutation.
All three shipped frontends read and write the same state.json shape and the
same 2.0 export files, so a collection moves between them unchanged.
database/*.json (38,540 games, 92 consoles, 733 collectibles) is compiled by
crates/memory_pak_core/build.rs into a single columnar binary blob that the
crate embeds. The blob is read in place: every string the catalog hands out
is a &'static str pointing into it, so opening the catalog costs one UTF-8
validation and no allocations.
Work that can happen at build time does:
- Strings are deduplicated into one arena, and records reference them by index — publishers and developers repeat thousands of times.
- Games are grouped by console (collectibles by collection) and sorted by slug, so filtering to one console is an index range and looking an entry up by id is a binary search.
- Sort orders are precomputed as permutations, so listing by title or year sorts nothing at runtime. Sorting by status, which depends on the user's own collection, is derived by bucketing one of those orders.
- Accent folding is generated for exactly the ~70 non-ASCII characters the catalog contains, so no Unicode normalization tables ship in the binary.
At runtime, collection flags are held in bit sets indexed by catalog position rather than a map keyed by id, which makes a filter check a bit test and "how many of this console's games are owned?" a popcount over a range. Queries walk a permutation, test the cheap filters first, and build a view struct only for the rows inside the requested window.
Measured on the full catalog (bun run bench), against the previous
postcard + HashMap + sort-then-paginate implementation:
| before | after | |
|---|---|---|
| Open the catalog | 20.2 ms | 0.46 ms |
| First page of all games | 28.9 ms | 0.08 ms |
Search mario |
60.3 ms | 1.6 ms |
| Build the initial UI state | 4.0 ms | 0.01 ms |
| Embedded catalog size | 4.60 MB | 3.02 MB |
Memory-Pak/
├── crates/
│ ├── memory_pak_core/ # catalog, query engine, state, import/export
│ ├── memory_pak_tauri/ # Tauri 2 desktop + Android frontend
│ ├── memory_pak_tui/ # ratatui terminal frontend
│ └── memory_pak_wasm/ # wasm-bindgen frontend for the browser/PWA
├── frontend/ # Svelte 5 + TypeScript + Vite (Tauri and web)
├── database/ # consoles.json, games/*.json, collectibles/*.json
├── icons/ # platform icons, shared by Tauri and the PWA
└── site/ # Svelte landing page; built PWA is copied to dist/app/
- Rust 1.94.0 (pinned via
rust-toolchain.toml); the workspace is Rust 2024 edition and needs 1.88 or newer - Bun 1.4.2
wasm-pack0.14.0 and Tauri CLI 2.11.1 — needed only for the web and Tauri frontends; the terminal frontend builds with cargo alone- Tauri platform prerequisites for desktop/mobile builds (Xcode CLT on macOS,
WebView2 on Windows,
webkit2gtkand friends on Linux)
bun run setup # wasm target, wasm-pack, tauri-cli, frontend and site depsbun run dev:web # Svelte/Vite PWA dev server
bun run dev:site # landing page dev server
bun run dev:desktop # Tauri desktop app
bun run dev:android # Tauri Android app
bun run dev:tui # terminal app
bun run build:web # production PWA build
bun run build:site # production landing page build
bun run build:pages # landing page + PWA ready to publish from site/dist
bun run build:desktop # desktop build, no installers
bun run build:android # Android package
bun run build:tui # release terminal binary
bun run package:desktop # installers for the current platform
bun run package:win # Windows NSIS + MSI
bun run package:mac # macOS DMG
bun run package:linux # Linux .deb + portable .tar.gz
bun run fmt # rustfmt
bun run lint # clippy, warnings denied
bun run test # Rust tests across the workspace
bun run bench # query engine latency
bun run verify # fmt + lint + test + wasm + frontend/site checks + web/site builds
bun run verify:full # verify + Playwright + desktop buildFrontend-only scripts live in frontend/package.json; landing-page scripts
live in site/package.json. Run them with bun run --cwd frontend <script>
or bun run --cwd site <script>.
memory_pak_tauri enables Tauri's custom-protocol feature by default, which is
what makes the window load the frontend bundled into the binary. That is a
compile-time switch and is not implied by --release, so cargo build --release -p memory-pak-tauri gives a working app while tauri dev --no-default-features (what dev:desktop runs) turns it off to pick up the Vite
dev server instead. Run bun run build:web first: the bundled assets come from
frontend/dist as it stood when the Rust crate was compiled.
Icons under icons/web/ are the canonical PWA icon source. Vite serves them at
/icons/... in dev and emits them to dist/icons/..., so there is no copy to
keep in sync. Generated WASM bindings go to frontend/generated/wasm/ and are
gitignored; the frontend scripts regenerate them before TypeScript or Vite runs.
Tauri mobile entry points are scaffolded through the standard CLI:
bun run android:init
bun run ios:initcargo run -p memory_pak_tui| key | |
|---|---|
↑ ↓ / j k |
move · PgUp PgDn Home End jump |
Tab / 1 2 3 |
switch between Consoles, Games, Collectibles |
/ |
search, Enter to keep it, Esc to clear |
o f w |
toggle owned / favorite / wishlist |
n |
edit notes |
s F g |
cycle sort, filter, console or collection |
e |
export to memory_pak_export.json |
q |
quit |
It also runs without opening the UI:
memory-pak-tui --path # the file this and the desktop app share
memory-pak-tui --import collection.json # merge in any Memory Pak export
memory-pak-tui --export collection.json # write one outOne file, shared by every native frontend. The desktop app and the terminal
app open the same state.json, so you can mark something owned in one and carry
on in the other:
| platform | |
|---|---|
| Windows | %APPDATA%\Memory-Pak\state.json |
| macOS | ~/Library/Application Support/Memory-Pak/state.json |
| Linux | ~/.local/share/Memory-Pak/state.json (or $XDG_DATA_HOME) |
A plainly named folder, not the reverse-DNS bundle identifier — this is something you open and back up, so it should read like a folder. The identifier still belongs to the installers and is unchanged.
Collections from earlier releases are migrated forward the first time they are
loaded, newest location first: <data dir>/com.Aspenini.MemoryPak, then the
older ProjectDirs path. The old file is left where it is, so an older build
still finds its data.
Set MEMORY_PAK_DATA_DIR to put the file somewhere else, such as a synced
folder; every native frontend honours it.
MEMORY_PAK_DATA_DIR=~/Dropbox/memory-pak memory-pak-tuiWrites are atomic — a temporary file and a rename — so an interrupted save cannot truncate a good collection. The frontends do not watch the file, so close one before making changes in the other, or the second one to save wins.
Android sandboxes every app, so it gets its own private copy in the app data directory. There is no other frontend on the device to share with.
The browser app cannot use that file at all. A web page has no filesystem
access, so the PWA keeps its collection in one IndexedDB record in the
memory-pak database, written debounced so rapid toggles coalesce.
Ordinary exports cross that gap, because there is only one export format and
every frontend both writes and reads it — there is nothing web-specific about
the file. Export from the browser app and --import it here or open it with the
desktop app's Import; export from either of those and load it with the browser
app's Import button.
Ids not present in the running build's catalog — from a newer release, or a hand-edited export — are kept verbatim and written back out, so moving a collection backwards between versions does not lose anything.
Desktop and Android installers are built locally; there are no packaging
workflows in this repository. The landing page and web app deploy to GitHub
Pages from master.
bun run package:win # Windows NSIS + MSI
bun run package:mac # macOS DMG
bun run package:linux # Linux .deb + portable .tar.gzBundles land in target/release/bundle/. Attach them to a GitHub release
yourself, together with checksums if you want them.
scripts/stage-release-artifacts.mts collects bundles plus checksums.sha256
into one directory.
The apps do not download or install updates themselves. They ask GitHub for the
latest release and, if that tag is newer than the running version, prompt to
open the release page. Linux packages still update through the distro or the
portable tarball; the web app is a rolling deploy of site/dist/app. AppImage
is not a supported release format.
Pushing to master (or running the Pages workflow by hand) builds the
Svelte landing page and the PWA and deploys site/dist/ to GitHub Pages.
The PWA is served from /app/. The first time, set the Pages source to
GitHub Actions under Settings → Pages.
To build the same tree locally:
bun run build:pages{
"version": "2.0",
"exportedAt": "2024-01-01T00:00:00Z",
"entries": [
{
"id": "console:nes",
"owned": true,
"favorite": false,
"wishlist": false,
"notes": "My original NES"
},
{
"id": "game:nes/super-mario-bros",
"owned": true,
"favorite": true,
"wishlist": false,
"notes": ""
}
]
}Every frontend writes and reads exactly this, through the same two functions in
memory_pak_core; there are no per-frontend variants, so any export imports
anywhere.
Entry ids are stable and derived from slugs — console:nes,
game:nes/super-mario-bros, collectible:legodimensions/batman — so exports
stay readable and diffable, and are sorted for byte-identical repeat exports.