RBR Copilot Installer
Richard Burns Rally · an esports-ready co-pilot · the ultimate second seat filler
Documentation: highpriest.github.io/RBR_copilot_installer — everything below in more depth, the architecture, and the API reference.
Introduction
This repository is the source of an installer for CoDriver configuration: pacenote packages for Richard Burns Rally and, in time, other rally games. It is public so you can see where the project stands, point out bugs, and say what you want from it.
It is a Rust rewrite of the original C#/WinForms installer, and it comes with its own package server instead of a third-party backend. Two things ship from here:
- The installer — one small Windows
.exe. It downloads packages, verifies them, keeps them in the game folder so a second run costs nothing, installs the ones you choose, and can put the folder back the way it was. - The server — one binary that serves those packages as plain files from a folder tree, with optional access keys for private content. Its only database is a small SQLite file holding those keys, which it creates itself. There is no upload API: publishing a package is copying a zip into place.
Using the installer
Download pacenotes-installer.exe from the
releases page
and run it. It is one file: no setup, nothing in Program Files, nothing in
the registry. It needs nothing else installed — no Visual C++
redistributable, no .NET — on Windows 10 or later.
The wizard, page by page. The button that moves you on is the green one.
- Language — English or Polish; it starts in your Windows language.
- Message of the day — what the server has to say, and a field for an access key if you have one. Leave it empty for public content. If the server cannot be reached you can carry on with what you already downloaded.
- Game folder — point it at the folder that holds the game’s own
executable; the game is recognised from that. If your Windows account
cannot write there (a game under Program Files, say), it offers to
restart as administrator, keeping what you chose. Then pick one of three
routes:
- Download/Update — get new content, then choose and install it.
- Configure/Install — change what is active, using only what is already on this computer: no server needed. It opens once each category has something downloaded.
- Restore/Uninstall — put the folder back to an earlier state, or remove everything this installer added.
- Download — a tree of everything available, laid out the way the packages are filed on the server, every row open. Tick what you want on this computer; a category with only some items ticked shows a partial box. What you already have is ticked and marked. Pick at least one item in each category the game needs (language, co-driver, style).
- Downloading — progress, the package being fetched, and a live log. A failure a retry could fix is retried; one that will not is named, and the rest carry on. Cancel works here, and nothing is written to the game folder yet. If a category is left with nothing, you go back to step 4; if nothing could be downloaded at all, the run ends at the summary.
- Configuration — the one pacenotes language, co-driver voice and style to use. Voices are listed with their full path, so two voices with the same name stay distinguishable. What is installed now is pre-selected, so changing one thing does not mean answering everything again.
- Installation — backup and install, with the same progress screen and log. No server needed. It will not start while the game is running from this folder.
- Summary — what was done, what was downloaded, and where the log is. The log is hidden when everything went well and shown when it did not.
Restore/Uninstall lists the restore points, newest first, plus “remove everything”, and previews what choosing one would put back, remove or leave alone before anything happens.
What a run leaves on your machine:
<game folder>\.pacenotes\ everything, and nothing anywhere else:
the downloaded packages, what was installed,
what was replaced, the restore points, and
one log per run
Nothing is written outside the game folder — not to your profile, not to the registry, not to Program Files. Deleting the game folder removes every trace of this installer, and copying the folder to another drive or PC brings your downloads and restore points with it. Downloads are named by content hash, so a second run re-uses them and works offline; two game folders each keep their own copy, which is the price of leaving nothing behind.
Versions before 2026-10-27 kept downloads in
%LOCALAPPDATA%\PacenotesInstaller. Nothing reads that folder any more, and
you can delete it.
Deploying the package server
One Podman Quadlet runs the server image, one Caddy site block puts it on HTTPS, and one directory holds everything that changes.
cp deploy/pacenotes.container deploy/pacenotes.config.env \
~/.config/containers/systemd/
systemctl --user daemon-reload && systemctl --user start pacenotes
Start with deploy/README.md. It covers the data
layout (which decides what the installer’s tree looks like), publishing
packages, optional metadata, the message of the day, access keys, and
operations.
Documentation
The documentation site
is an mdBook built from this repository on every push to master: every
document here, the architecture diagrams (written in D2
and rendered to SVG), and the rustdoc API reference. Build it locally with
docs/build.sh, which needs d2 and mdbook on the PATH;
DEVELOPMENT.md has the details.
| Document | Covers |
|---|---|
CLAUDE.md | The architecture rules: the size budget, the install model, how the server’s folder tree becomes the installer’s tree, the auth model, the protocol. |
installer/CLAUDE.md | The installer’s rules and module map. |
installer/DESIGN.md | The wizard’s nine pages and three routes, screen by screen, and the Win32 behaviours worked around to build them. |
server/CLAUDE.md | The server’s rules and the security invariants around package lookup. |
DEVELOPMENT.md | Prerequisites, building, what every test proves, running a server locally, releasing. |
installer/tests/README.md | How the installer’s tests are written, and the traps in driving Win32 from a test. |
TASKS.md | Milestones M0–M7, with the detail in installer/TASKS.md and server/TASKS.md. |
CHANGELOG.md | What changed, and why. |
Development
cargo build && cargo test # shared, server and the docs tool; any OS
cd installer # Windows, MSVC
pwsh -File tests\Run-AllTests.ps1 # lint, tests, size gate, the GUI suite
| Path | What it is |
|---|---|
shared/ | The protocol types both sides compile against. serde only: no I/O, no HTTP. |
installer/ | The Windows GUI. Win32 and WinHTTP, no GUI framework, a hard 700 kB size gate. |
installer/src/ | The library (tree/, choices/, cache/, ledger/, engine/, restore/, zip/, …) and the Windows application (wizard/, worker/, http/, ui/, i18n/). |
installer/res/installer.rc | Dialog templates, every user-visible string in each language, the icon, version info. |
installer/tests/ | The scenario tests, the size gate, and the PowerShell suite that drives the real UI. |
server/ | The axum + libSQL package and auth server. |
schemas/ | Each game’s install schema, embedded in the installer and served by the server. |
deploy/ | The Containerfile, the Podman Quadlet, the Caddy block and the deployment guide. |
docs/ | The documentation site: the book, the D2 diagram sources, build.sh. |
tools/mdbook-repo-links/ | The book’s preprocessor, which points links to source files at GitHub. |
Read the CLAUDE.md of the directory you are about to change: they hold
the decisions that are not visible in the code.
Contributing
To the CoDriver content — this repository is only the installer. For bugs in the pacenotes themselves, or to contribute a co-driver, go to the Richard Burns Rally CoPilot source repository.
To the installer or the server — pull requests are welcome. Run
installer/tests/Run-AllTests.ps1 (or cargo test at the root for
server-side work) before opening one, and keep the task lists and
CHANGELOG.md up to date with what you did.
License
MIT — © 2026 HighPriest. Also declared as license = "MIT" in
the workspace Cargo.toml, which is what the crate metadata carries.
Installer UI design — wizard flow
The installer downloads, caches and installs mod packages for one game per run. Supported games, detected by their marker executable:
| Game | Marker executable | Game id (pacenotes_shared::GAME_*) |
|---|---|---|
| Richard Burns Rally | RichardBurnsRally_SSE.exe | rbr |
| Dirt Rally 2.0 | dirtrally2.exe | dirtrally2 |
| Assetto Corsa | assettocorsa.exe | assettocorsa |
| BeamNG | BeamNG.drive.exe | beamng |
Flow
Eight pages, and TWO ROUTES through them. Scene 3 is where they part:
1 Language → 2 Message of the day → 3 Game folder
│
Download/Update ──────────────┤
│
4 Download → 4b Downloading → 5 Configuration → 6 Installation → 7 Summary
▲
Configure/Install ────────────┘
- Download/Update: choose content, fetch it into the cache, THEN answer the configuration questions, THEN install.
- Configure/Install: skip both download pages. The questions are answered against what this computer already holds (the cache) and what this folder already has (the ledger).
Downloading and installing are deliberately two screens with the configuration step BETWEEN them, rather than one progress screen that did everything:
- The download only fills a cache; the install writes to the game folder. Someone can fetch a voice pack at home and install it on a train.
- A user who only wants to change which co-driver is active should never wait for, or depend on, the server. That is the whole Configure/Install route, and it is the common case after the first run.
- The questions are easier to answer once the content is actually here: a list built from the cache cannot offer something that failed to download.
Common chrome on every page: a title, the content area, and a footer with Back (scenes 3–5 only), the page’s primary button (its label differs per page) and Cancel (asks for confirmation; disabled on scene 6, where every phase writes to the game folder). No going back once scene 4b starts.
The button that carries the normal flow forward is light green: the primary button in each of its labels (Continue, ACCEPT, Download, Install, Restore, Finish) and scene 3’s Download/Update. Nothing else is, so the eye finds the way on without reading. A disabled one keeps the theme’s grey, since green there would point at a way that is not open. Back, Cancel and the two secondary routes keep the theme’s look.
The window is ONE size for every scene — a property sheet sizes itself to its largest page — so a scene with little content, such as scene 1, simply has space around it. That size is 470x300 DLU, about 860x680 px at 100%: the most that fits a 1024x768 screen above its taskbar, so the Download tree and the logs show as much as they can. It was 330x215 (615x512 px, an 800x600 screen) until 2026-11-01. Scene 5 sets the shape: three lists have to be visible at once, and each at full width, because a co-driver is identified by its whole path and a narrower column clips exactly the part that tells two voices apart. Every page keeps its arrangement; the extra height goes to the parts that hold lists.
Scene 1 — Language
- Language combobox + Continue.
- Default selection = OS UI language if supported, else English.
- The choice applies to the whole run (
SetThreadUILanguage). No language switch later; restart the app to change it. - This scene is shown BEFORE a language has been chosen, so it necessarily renders in the OS UI language.
Scene 2 — Message of the day
- Read-only multi-line text: the MOTD from the server (
GET /motd?lang=xx, English fallback). Offline → built-in localized text saying the server is unreachable and only cached content is available. A 404 from the server (no MOTD published) is treated exactly like being offline. - The file’s own line breaks are kept, LF or CRLF: an edit control breaks lines only at CRLF, so the text is re-joined with CRLF before it is shown.
- Secret field (password style). The secret unlocks extra (private) content: it is the bearer access key for every later server request. It is typed each run and never remembered: storing it would mean the user profile, since no game folder is chosen yet, and a key left behind on a shared PC is worse than retyping it. Only a restart as administrator carries it over (scene 3).
- Bottom line: Server status: Online / Offline, from
GET /healthon the worker thread when the scene opens (“Checking…” until it answers). The line is ALL/healthdecides: scene 4 asks for the catalogue whatever it said (DESIGN-REVIEW D12). - ACCEPT:
- empty secret → continue anonymously;
- secret + online → validate with
GET /whoami; 401 → warning modal “invalid secret”, stay on the scene; - offline → continue (secret kept, unvalidated).
GET /whoami, notGET /packages:/packagesnever answers 4xx — it silently falls back to the public listing for a key it does not accept, so directory names cannot be enumerated, which also made a mistyped key look exactly like a good one. Anything other than a clean 401 (a dead server, a 200 we cannot parse) counts as “could not ask” and continues with the secret unvalidated; locking a user out of content they paid for over a protocol change would be the worse failure./whoamialso returns when the key expires, and a key within a fortnight of that says so in an informational modal with the date. A key that lapses between two runs otherwise looks exactly like content being withdrawn.
Scene 3 — Game folder
-
Path edit + Browse (folder picker) + game dropdown.
-
Dropdown starts disabled showing the placeholder (Detecting).
-
After Browse returns, and on path edit (debounced ~400 ms), scan the folder for the marker executables:
- exactly one match → dropdown shows that game (disabled: nothing to pick);
- several matches → dropdown enabled, listing only the matched games;
- no match → dropdown back to (Detecting).
-
Warning modal when no marker is found — after Browse returns and when a route button is pressed, never per keystroke.
-
Three route buttons in two columns, all enabled only when a game is detected/selected, with a line above them saying what each one does:
┌───────────────────┐ ┌─────────────────────────┐ │ Configure/Install │ │ │ ├───────────────────┤ │ Download/Update │ │ Restore/Uninstall │ │ │ └───────────────────┘ └─────────────────────────┘- Download/Update → scene 4. The MAIN flow, and the shape says so: the default button, the widest, and as tall as the other two together, alone on the right where the eye ends up and where the sheet’s own Next sits.
- Configure/Install → scene 5, skipping both download pages — but only when every scene 5 question has something on this computer to choose from. Otherwise a warning names the empty categories, says to download first, and the wizard stays here.
- Restore/Uninstall → the restore scene below.
- The two secondary ones are work on a folder you already have, so they stack in a narrower column on the left. The stack’s top and bottom are exactly the big button’s, so the three read as one block, and the gap down the middle is what separates secondary from primary.
-
RECORDED DEVIATION: these are buttons IN THE PAGE, and the sheet’s own Next is left disabled on this scene. They are alternatives rather than one primary and two afterthoughts, each is one click instead of select-then-Next, and three routes do not fit on one Next anyway. Back and Cancel are unaffected.
- The keyboard follows the same order. Enter in the folder field takes Download/Update, as the page draws it as the default: the sheet’s own Enter would go to its disabled Next and do nothing, so the field claims the key. Tab runs folder → Browse → game → Download/Update → Configure/Install → Restore/Uninstall, the main route first, though it is drawn on the right.
-
Keyboard on the other scenes: each opens with the focus on its one input (the language list, the secret field, the tree, the first list), Enter presses the sheet’s primary button, and Esc asks before closing on every scene where Cancel is offered. Where it is not (6, while the run writes to the game folder, and 7) the dialog manager ignores Esc.
-
The run log moves into this folder here (
.pacenotes\logs\), replaying the lines from before a folder was known, and later panics are written to the same file. Pressing a second route button does NOT start a second log: one file per run. See “Logging”. -
A folder this account cannot write is caught here (P11), when a route button is pressed — every route writes into the folder, and this is where the user commits to it. The probe creates
.pacenotes\and a file in it, and removes whatever it created, so a folder the user then abandons keeps no trace. Without it, a game underProgram Filesfailed at scene 4b’s downloads, after everything had been chosen.- The message names the folder, says that is usual under Program Files, and offers to restart as administrator (UAC). No — or cancelling the UAC prompt — leaves the user on scene 3 to pick another folder.
- The elevated instance opens straight on scene 3 with the language, the folder and the access key carried over, so nothing is asked twice. The key travels on that process’s command line, readable by other programs running as this user; the maintainer accepted that for a seamless restart. It waits for this instance to close rather than reporting “already running”.
- Already elevated and still not writable (read-only media, a deny rule): the message says so and offers nothing, since asking again would loop.
- Files an elevated run installs belong to Administrators, so later runs on that folder need elevation too, and the probe will say so.
-
When the folder is settled, its download cache is swept of what nothing needs: half-finished downloads, and payloads no longer named by the cache index, the installed packages or any restore point. Older versions a restore point names stay, or “take me back to yesterday” would quietly lose its way back. The run log says what went and how much space it freed. There is no “remove from cache” control.
Restore scene (reached from scene 3’s Restore/Uninstall)
The case this exists for: a user has been handed a bad package. They do not want the installer gone, they want yesterday back. So Restore is “take this game folder back to how it was at a point in time”, not “copy some files back”.
-
A list of restore points, newest first, each a date and what the run after it did:
2026-10-27 12:31 +4 -0 ~0(added, removed, changed version). The counts are derived by diffing consecutive snapshots — nothing records “what this run was about”, and a column of identical dates is not something anyone can choose from. Signs rather than words because the line must not need three plural forms in Polish. -
The last entry is “Remove everything this installer added”, which is a restore to an empty state: the same operation, not a second code path, and the other half of the button’s name.
-
The selected point shows a preview before anything happens — the whole point of restoring after a bad package is knowing what you are getting back. Removals come first, because that is the part to read before agreeing, and packages are named by their leaf the way every other scene names them:
Remove: Kamil Numeric Standard Put back: Ewa Leave as it is: polish -
Restore then runs on the progress scene (scene 6) like any other phase, with the same log. The PLAN the preview showed is the value the worker is handed, so what runs cannot differ from what was approved.
-
A restore takes a restore point of its own first. It changes the folder like any other run, and a user who restores to the wrong state must be able to come back from that too.
Restoring is TWO operations, and the second is the one the legacy installer never had:
- Put back the files replaced since that point. Each file carries, in the ledger, the point holding what IT replaced, so the right copy is named rather than searched for.
- Remove what was installed after that point. Copying files back cannot do this: a broken package that ADDED files leaves every one of them behind, and the user still has the broken install. This needs to know what was installed and when, which is what the ledger snapshot inside each restore point is for.
Two rules that cost more code than the happy path, and are the difference between a restore and a mess:
- Never make it worse. A package whose payload is no longer in the cache cannot be put back — so it is NOT removed either. The version installed now, wrong as it may be, beats an empty folder. The preview names it under “CANNOT be put back” before the user commits, and the rest of the restore still happens.
- Restore the provenance, not just the bytes. The snapshot records,
per file, what was there before this installer first wrote to it. A
fresh install of the old version would record
prior: absentfor a file it just deleted and rewrote, and a later uninstall would then DELETE a file the user had all along. So the ledger written afterwards takes the snapshot’s entries for everything restored.
The restored state also re-runs the game’s configuration step, with the picks that state implies. Otherwise the files come back but the user’s chosen language and voice do not: packages ship their own defaults, and what made them the user’s choice was an edit to a file the install just rewrote.
A restore point is therefore .pacenotes\restore\<timestamp>\ holding the
replaced files plus ledger.json as it was before that run. Restoring
replaces the live ledger with that snapshot, so the installer’s own record
matches the folder afterwards — otherwise the next run would “uninstall”
packages that are no longer there and reinstall ones that are.
Two runs CAN share a timestamp — the stamp is accurate to the second, and
re-running the wizard straight away is a normal thing to do. The second
run then gets …31Z-2, because reusing the directory would overwrite the
first run’s snapshot and its saved files: silently destroying the way back
the first run had promised.
Points are never pruned automatically. They are small (only files actually replaced, and never the byte-identical ones), and the one a user needs is the one an automatic policy would have deleted.
Scene 4 — Download
-
One tree view with checkboxes, multi-level:
[ ] STYLE [ ] FILE_NAME [ ] LANGUAGE [ ] FILE_NAME [ ] PACENOTES [ ] FILE_NAME [ ] CODRIVER [ ] LANGUAGE [ ] GENDER [ ] FILE_NAMEThe shape comes from where each package SITS on the server:
PackageInfo.nameis its path under the access directory, socodriver/polish/male/Kamilgives level 1 =category(codriver), middle levels (polish,male) and the leaf (Kamil). -
The tree opens with every row expanded, so each package is in view without a click. It opened collapsed until 2026-11-01, to keep every category that needs a pick on screen; with a long co-driver list the categories below it are now a scroll away, which the larger window offsets.
-
Only packages for the detected game are shown; the manifest is fetched with
?game=<id>so the server filters rather than the client. -
Loading order:
- server manifest (with the secret, if any);
- merge the download cache: downloaded leaves get the suffix “(downloaded)” and are checked by default; cached packages not on the server are added with “(local only)”; cached packages with a newer server version get “(update)”.
-
Offline: tree built from the cache only. “Offline” means the catalogue request itself failed — it is always made, even when scene 2’s
/healthfailed or has not answered yet, because one dead endpoint (a load balancer hiccup, a slow/health) must not hide a catalogue that would have come back. When both failed the status says the server could not be reached; when only the catalogue did, that the list could not be read. A catalogue that answers after/healthdid not is noted in the run log. -
Checking a parent checks all its descendants; mixed descendants show the parent as partially checked, with the partial box (a filled square), and a click on a partial box ticks all of it, as tri-state boxes do elsewhere.
-
Unchecking a downloaded leaf only excludes it from this run; it is not removed from the cache.
-
Download: requires at least one checked leaf in every choice category the game declares; otherwise a warning modal names the missing ones. Any other category the operator publishes —
pacenotes, or a newextras/folder — is optional, because adding a folder on the server must not add a question every user is forced to answer. -
Nothing here gates
exclusive_group. Two members of one group can be ticked and both will install, the later winning; see “Overlapping files” ininstaller/CLAUDE.mdfor why that is preferred to blocking the user. -
The button commits the selection; the transfers themselves run on scene 4b, which this page walks straight on to.
-
If the game’s install schema is not one this build knows, Download stops with “a newer installer is needed”, because the catalogue may use categories this build does not know how to place. The manifest carries the SHA-256 of each game’s schema file (raw bytes) and the installer compares it with the copy it embeds from
schemas/<game>.toml; see “Install-schema check” ininstaller/CLAUDE.md.- It blocks downloading for THAT GAME only, and only downloading. Reconfiguring, repairing, uninstalling and restoring what is already on the machine stay available — none of them consults the server.
- The warning is raised by the Download button, not at startup, and only when something actually needs fetching.
- An offline run never raises it: the check runs on a manifest that came from the server, not on a tree rebuilt from the cache.
Scene 4b — Downloading
The same screen as scene 6 — progress bar, phase label, current item with size, log pane — at an earlier moment, and a page of its own because what happens between the two is the user answering the configuration questions.
- Starts itself on activation and needs no button: it fetches everything TICKED on scene 4 that is not already in the cache, verifies each payload against the manifest’s sha256, and walks on to scene 5 when done.
- Nothing to fetch (a re-run, or everything already cached) is the normal case, not an edge: the page says so in the log and moves on.
- Nothing is written to the game folder here. That is what makes the split worth having, and what the test asserts by checking there is no ledger when scene 5 appears.
- Cancel is allowed, and this is the only phase where it is: a half-finished download costs nothing and is discarded.
- A failure a retry could fix is retried (DESIGN-REVIEW §7): no response at all, 408, 429, 5xx, or a payload that arrived damaged (its sha256 did not match). Three attempts, waiting 1 s and then 3 s, each retry a line in the log; Cancel works during the wait. A 401 or 404 will not change, and a full disk is not the network’s fault, so those fail at once.
- One package that still fails does not end the run. It is logged, the others carry on, and scene 5 does not offer it — unless the cache holds a copy: a failed UPDATE keeps the installed version, since leaving it out would make the plan uninstall it. Scene 7 names what never arrived.
- When NOTHING could be downloaded, or the user cancels, the run ends at scene 7 rather than continuing to the questions: there is nothing new to configure that could honestly be installed.
- When a question would have nothing to choose from — every ticked package of a choice category failed — a warning names the category and the page returns to scene 4, where those packages are now unticked. The next selection runs a transfer of its own, fetching only what the cache does not hold yet. The rule above comes first: a transfer that brought nothing at all is a server or network failure, which the summary and its log explain better.
- Back is not offered; the configuration scene’s Back returns to scene 4, skipping this page, because there is no going back into a finished transfer.
Scene 5 — Configuration
-
Three single-select lists, filled from leaves checked in scene 4:
List Category Pacenotes Language languageCoDriver Voice codriver(shown with its path, e.g. “polish / male / KamilFilipek”)Pacenotes Style style
The three lists are not a fixed set: they are the categories this build’s
schema for the game marks select = "one", in schema order
(schema::choice_categories). The localized label and one-line explanation
of each come from the .rc string tables, mapped by category id — never
from the server. A game whose schema declares no single-pick category skips
this scene.
- What this folder already has installed is the default answer, which is what makes the Configure/Install route usable: that route exists to CHANGE an install, so arriving with every question blank would make the user re-answer what they answered last time — and a run whose selection omitted an installed package would UNINSTALL it. A pick the user makes themselves wins over it.
- A list with exactly one option is preselected.
- On the Configure/Install route the options come from the cache and the ledger alone; the page is otherwise identical, because it reads the same tree either way.
- Checked
pacenotes-category packages are installed as-is (no choice), and so is anything under a category the installer does not know. - The scene opens only when every list has something to offer: a checked package of that category whose payload is in the cache. Both routes check this on the way in — Configure/Install on scene 3, Download after scene 4b — so the user never meets a question they cannot answer. This reverses an earlier choice (2026-11-01, at the maintainer’s request): a category the server publishes nothing under used to leave an empty list and install without it; now it holds the wizard short of this scene, and the operator has to publish one. An empty list still says so, as a fallback that should no longer be reachable.
- Install requires a pick in every list; a warning modal names the ones still unanswered.
- Going Back rebuilds the lists. A pick still on offer is kept; one whose package was unticked is dropped and the question is asked again. Back leads to scene 4 on the Download route and to scene 3 on the Configure/Install route — where the user came from, which on neither route is the previous page.
Scene 6 — Installation
- Overall progress bar, phase label, current file with size (“Numeric 1to7.zip — 1.2 / 4.8 MB”), live log pane. Same layout as scene 4b; the download phase is no longer part of this page.
- Phases, in order:
- Check the plan against the folder (free space, CRCs, and that the game is not running FROM THIS FOLDER — it holds its files open, so the install would fail partway. A copy running from another folder does not count; one whose path cannot be read does, to be safe. The same check guards a restore.);
- Uninstall what a new pick supersedes — before anything is written, because the style packages all own the SAME 49 paths and uninstalling after installing would delete the new one’s files;
- Backup files the install will overwrite and the installer does not
already own (first touch only, under
.pacenotes\backup\in the game folder), skipping any whose bytes already match what will replace them; - Install the three configured packages + checked
pacenotespackages; - Configure — the chosen game’s own step, if it has one. RBR’s sets the pacenotes language in the Pacenote plugin’s config. Other games get their own step or none; see “Per-game configuration” below.
- Everything this page installs from is already in the cache: scene 4b saw to that, or the user took the Configure/Install route because it was already there. The page therefore works with the server unreachable.
- Backup needs each package’s file list, which comes from reading the package’s own zip — another reason the download has to have finished before this page starts.
- Scene 4b downloaded everything TICKED on scene 4, not just the scene 5 picks. The user ticked what they want to have; they chose separately what to install.
- Files that more than one selected package writes do NOT stop the run: the
later package wins, the log says which files and which packages, and the
installer tells the server (
POST /report/conflict, after the install, so what is reported is what happened). The operator is the only party who can fix a catalogue like that, and a user must never be blocked by a mistake they cannot fix. The report carries the game, this installer’s version and the clashingdirectory/namerefs in install order — no identity, no local paths, no token — and failing to send it is a log line, nothing more. - The scene has no buttons of its own: no Back (no going back once it starts) and no Next, because it walks on to the summary by itself when the run ends.
- Cancel is disabled here. Every phase on this page writes to the game folder and the engine has no way to stop between files; a button that silently did nothing would be worse than none. Cancel belongs to scene 4b, where the only thing in flight is a download.
- On error: stop, keep the log visible, go to scene 7 in failure state.
Scene 7 — Summary
- Result: success / failure.
- What was done: game + folder, the three installed choices,
pacenotespackages, number and total size of downloads, what the game’s own configuration step changed, and the backup location — named only when something was actually backed up, since a run that replaced nothing of the user’s has no backup to point at. - What could not be downloaded, named, when scene 4b left anything out.
- Log pane: collapsed on success, expanded on failure — and when a package could not be downloaded, since the reason is in the log. Collapsed means hidden with the detail block stretched over its room, not a smaller box — a successful run’s log is noise and a failed one’s is the point.
- Cancel is disabled here: the run is over, and Finish is the way out.
- Finish closes the app.
Logging
Every scene appends to an in-memory log, also written to
<game dir>\.pacenotes\logs\install-<utc stamp>.log. The log is visible on
scenes 4b and 6 and again on 7; any error shows it.
- In the game folder, like everything else this installer writes. It sits with the cache, the ledger, the backups and the restore points it describes, so a user asking for help about a game folder has all of it in one place and nothing is scattered through their profile.
- One file per run, dated in the name. A single overwritten file meant that re-opening the installer to see what went wrong ERASED the run being looked at (DESIGN-REVIEW §7). The newest five are kept; a log is a few kB.
- The file is attached when scene 3 settles the folder, and the lines from before that — the language, the server, the catalogue — are replayed into it, so the file is the whole run and not just its second half.
- Every line is flushed as it is written: a run that dies still leaves the line saying what it was doing.
- The file opens with what support asks for first (DESIGN-REVIEW §7):
the build (
Pacenotes Installer 1.2.3, from the release tag), when the run started, the server it talked to, and the Windows build — the real one, fromRtlGetVersion, sinceGetVersionExWreports Windows 8 to a program whose manifest names nothing newer. A blank line ends the header. - Every line in the file says when it was said, in UTC and in the
server log’s own shape (
2026-10-31T12:11:31Z installed …), so a user’s log can be laid next to the server’s. A line replayed when the folder is settled keeps the time it was said, not the time it was copied. - The pane shows neither the header nor the times: they are for whoever reads the report, and a user reading along needs only the lines.
- A panic is written down too. This is a GUI-subsystem binary built
with
panic = "abort", so a panic has nowhere to print: the process vanishes and Windows records an offset into a stripped exe, which tells nobody anything. A panic hook (main.rs) catches it first and writespanicked at <file>:<line>: <message>into the run log — or, before a game folder is known, intopacenotes-installer-crash.logbeside the exe.- What the USER sees is localized and plain: something went wrong, where it was written, that the game folder is not half-installed, and “Show that file?” — which opens Explorer with the log selected. A report the user cannot find is one they cannot send. The Rust text stays in the file, for whoever reads the report.
- The message is loaded after re-applying the chosen language, because the hook can run on the worker thread, which never had it set.
- Only the FIRST panic gets a window. The modal pumps the message queue, which can deliver the very message that panicked, and one real fault was observed turning into a cascade of boxes.
- It covers the worker thread as well: the hook is global, and with
panic = "abort"a worker panic takes the process with it either way. - It does NOT cover access violations or other hardware faults in Win32 code — those never reach a Rust panic hook.
Implementation notes
Installer state in the game folder
One directory, the same for every game:
<game folder>\.pacenotes\installed.json the ledger
<game folder>\.pacenotes\backup\<rel path> first-touch backups
Not inside the plugin tree. Plugins\Pacenote\installer\ is where the C#
version kept it, but that path exists only for RBR, and for RBR it sits in
a directory that packages and the RSF installer also write to — something
else cleaning up there would take the ledger with it. One location means
one rule, no per-game table, and all installer state is one thing to find,
copy with the folder, or delete.
Per-game configuration
After the install phase the chosen game may run a step of its own. There is deliberately no shared abstraction for these: the games do not share a file layout, several have no plugin system at all, and a step may have to edit the game’s own files rather than a plugin’s config. A single “ini key = value” mechanism would fit RBR and mislead everyone adding the next game.
Each game therefore owns its step outright, and the only contract is the plumbing: a step reports the files it wrote or changed, and those are ledgered and backed up exactly like extracted ones, so uninstall and restore need to know nothing about what the step did.
RBR’s step modifies the Pacenote plugin’s Plugins\Pacenote\PaceNote.ini:
- The co-driver packages already ship that file, complete, with
sounds=<the voice's own path>and the author’s ownlanguage=,muteReplayPacenotesand comments. So the step EDITS it — settinglanguageto the user’s pick, andsoundsto the chosen voice — and leaves every other line exactly as the author wrote it. - If the file is not there, it is written from a built-in template holding the plugin’s documented defaults.
- If
Plugins\Pacenote\config\is missing entirely, the plugin is not installed: the step reports that, as the C# version’s message did, rather than writing config for something that is not there.
What it does NOT do is regenerate the file the way CreateRBRConfiguration
did. The packages have outgrown that: a regenerated file would discard each
voice’s muteReplayPacenotes, the author’s comments, and any key the plugin
gains later. The style half of that old function is obsolete outright — the
style packages now ship the config\pacenotes\*.ini files it used to write,
with the correct file0=packages\FilipekMod_<style>.ini per style.
Wizard host
comctl32 PropertySheet in wizard mode (PSH_WIZARD): Back/Next/Cancel,
page switching and keyboard handling come from the OS at ~0 bytes of ours,
which matters against the 700 kB gate. One dialog template per scene in
res/installer.rc; button enablement via PSM_SETWIZBUTTONS.
Three things the OS does NOT give, each worked around in wizard.rs with the
reason at the call site:
- The scene title is a static control in each page template, not the
Wizard97 header. Wizard97 draws its header title only alongside a header
BITMAP; without one it reserves an empty band and ignores both
PROPSHEETPAGEW::pszHeaderTitleandPSM_SETHEADERTITLE. Shipping a bitmap to satisfy it would spend size budget on decoration, and a static is localizable per scene anyway. - Per-scene button labels are written directly to comctl32’s own button
ids (
ID_WIZNEXT/ID_WIZFINISHfrom prsht.h).PSM_SETNEXTTEXTis documented for this and is ignored by the sheet. Back and Cancel are relabelled the same way, because comctl32 supplies them in the OS language and a Polish wizard would otherwise carry two English buttons. - The title bar is set with
WM_SETTEXTwhen each scene activates;PROPSHEETHEADERW::pszCaptionhas no effect.
Localization
Per-language STRINGTABLEs in res/installer.rc plus SetThreadUILanguage,
so LoadStringW resolves the right table with no lookup code of our own.
Every caption is set from the string table at WM_INITDIALOG; the text in the
templates is an English fallback only.
Tree
SysTreeView32 + TVS_CHECKBOXES, partial state via
TVS_EX_PARTIALCHECKBOXES; downloaded leaves bold (TVIS_BOLD) + suffix.
Threading
All network work on the worker thread (worker.rs); the UI thread is the only
one that touches controls. A worker posts a message and the UI thread acts on
it.
Protocol
GET /packages?game=<id>— the manifest for one game.GET /download/{game}/{directory}/{*name}— build the tail withPackageInfo::download_path(); never assemble it by hand. A name may need percent-encoding (e.g.style/Numeric Standard).GET /motd?lang=xx,GET /health.- A package’s identity is
game+directory+name. “directory/name” is NOT unique across games, so the cache index and the install ledger must key on the game as well — seeinstaller/CLAUDE.md.
Deploying the Pacenotes server
Written for: whoever administers the box that hosts the package server.
GitHub Actions builds the server image when a release is tagged, one Podman Quadlet runs it, one Caddy site block puts it on HTTPS, and one directory holds everything mutable. There is no database to provision and no upload API: publishing a package is copying a zip into place.
Containerfile— multi-stage build ofpacenotes-server(build context is the repository root). CI builds it; the host no longer does.entrypoint.sh— regeneratesmanifest.json, then serves.pacenotes.container— the Quadlet unit. Runs the local imagelocalhost/pacenotes-server:current; never builds, never pulls.pacenotes-update— the only thing that changes which image runs: pulls a released version, moves:currentonto it, restarts, rolls back if the new container is unhealthy. CI runs it over SSH.pacenotes.config.env— all configuration. Copy and edit this.Caddyfile.snippet— the reverse-proxy site block.
How a release reaches this host:
git tag v/2.1.0 && git push github v/2.1.0
└─ .github/workflows/cd-release-on-tag.yml
release installer .exe -> GitHub Releases (public)
server server image -> ghcr.io/<owner>/pacenotes-server:2.1.0 (private)
deploy ssh host "2.1.0" -> ~/.local/bin/pacenotes-update 2.1.0
---
## 1. One-time setup
### 1.1 Create the data directory
Everything the server can change lives in one place, so this is also the only
thing to back up:
```sh
mkdir -p ~/pacenotes/data/rbr/packages/public
mkdir -p ~/pacenotes/data/motd
# Every game needs its install schema, or NOTHING is published for it:
cp schemas/rbr.toml ~/pacenotes/data/rbr/schema.toml
Layout (see root CLAUDE.md for the reasoning — the folder tree IS the
installer’s UI tree):
~/pacenotes/data/
├── manifest.json generated; never hand-edit
├── auth.db credentials; the one irreplaceable file
├── motd/
│ ├── en.txt message of the day, per language
│ └── pl.txt
├── reports/ conflict reports from installers (S10)
└── <game>/ rbr | dirtrally2 | assettocorsa | beamng
├── schema.toml the game's install schema (see below)
└── packages/
├── public/ visible to everyone
│ ├── language/polish.zip
│ ├── style/Descriptive.zip
│ └── codriver/polish/male/KamilFilipek.zip
└── <other-directory>/ visible only to granted access keys
Rules that will bite you if ignored:
publicis a level of its own.rbr/packages/codriver/...publishescodriveras an access directory, and every package’s category shifts up a level.manifest genwarns about exactly this.- Game ids and directory names are
[a-z0-9_-]+— lowercase, no spaces. - Package names may contain spaces (
style/Numeric Standard.zipdoes), but a client has to percent-encode them.manifest genwarns so you can rename. motdandreportsare reserved names at the top level: never games.
1.1a The install schema (one per game)
<game>/schema.toml lists the game’s categories — the folders directly
under an access directory — whether each asks the user for exactly one pick
(select = "one") or is optional ("any"), and where it installs
(install_root, a token such as "game", never a path):
[[category]]
id = "codriver"
select = "one"
install_root = "game"
manifest gen is strict about it, because this is what tells an installer
whether it understands your catalogue:
- no
schema.toml→ nothing is published for that game (anERROR); - a schema that does not parse → nothing for that game (an
ERROR); - a package in a category the schema does not declare → that package is
skipped (a warning). Adding a folder like
extras/is a schema change.
The manifest carries each schema with the SHA-256 of the file’s exact bytes. An installer compares that with the schema it was built with and, if they differ, refuses to DOWNLOAD for that game (reconfiguring and uninstalling keep working). So:
- Copy schemas from the repository’s
schemas/<game>.tomlwithcp— never retype or reformat them. Even a changed comment or a CRLF line ending is a different schema to every installer. manifest genwarns when your copy differs from the one this server release was built with: that is the “installers will refuse” moment.- Changing a schema is a release, not an edit. Ship an installer that accepts both the old and the new schema first; only then copy the new file onto the server. In the other order every installer in the wild stops downloading for that game until its users update.
1.2 Install the unit and the update script
cp deploy/pacenotes.container ~/.config/containers/systemd/
cp deploy/pacenotes.config.env ~/.config/containers/systemd/
install -D -m 755 deploy/pacenotes-update ~/.local/bin/pacenotes-update
systemctl --user daemon-reload
Edit ~/.config/containers/systemd/pacenotes.config.env. Set
PACENOTES_IMAGE to ghcr.io/<github owner in lower case>/pacenotes-server;
leave PACENOTES_LISTEN=0.0.0.0:8080 alone (on 127.0.0.1 the container
would be unreachable from Caddy).
The image is a private GHCR package, so the host needs its own
credentials — a GitHub token (classic, scope read:packages and nothing
else) used only for this:
podman login ghcr.io --username <github user> \
--authfile ~/.config/containers/ghcr-auth.json # paste the token
That path is PACENOTES_REGISTRY_AUTHFILE in the env file.
1.3 Start it
The first deployment is by hand, with a version that CI has already published (see §1.5):
~/.local/bin/pacenotes-update 2.1.0
journalctl --user -u pacenotes -f
It pulls the image, tags it localhost/pacenotes-server:current, starts the
unit and waits for the healthcheck. From then on CI does this on every
release, and systemctl --user restart pacenotes only ever restarts the same
image — a restart never builds, never pulls, and works with GitHub down.
Moving from the old build-on-start unit: the previous Quadlet built
localhost/pacenotes-server:latest from git at every start. Copy the new
unit, daemon-reload, then run pacenotes-update once as above; the old
:latest image can be removed with podman rmi once the new one is healthy.
Check it from the host, without going through Caddy:
podman exec pacenotes curl -s localhost:8080/health # -> ok
podman exec pacenotes curl -s localhost:8080/packages | head -c 200
1.4 Put it behind Caddy
Append deploy/Caddyfile.snippet to ~/caddy/caddy_etc/Caddyfile (adjust
the hostname), then — if you want the internal http:// block as well — add
the alias to ~/.config/containers/systemd/caddy.container:
NetworkAlias=pacenotes.example.com
Reload (a Caddyfile edit alone needs no restart; the NetworkAlias does):
systemctl --user reload caddy # Caddyfile only
systemctl --user restart caddy # if you touched caddy.container
curl -s https://pacenotes.example.com/health
DNS must already point at this host, as for every other *.example.com site.
Caddy fetches the certificate itself.
1.5 Deploying from GitHub
The release workflow (.github/workflows/cd-release-on-tag.yml) runs on a
pushed v/X.Y.Z tag. Its deploy job logs into this host with a key that
can do exactly one thing: run pacenotes-update with a version.
-
A deploy key, for nothing else. On any machine:
ssh-keygen -t ed25519 -N '' -C pacenotes-deploy -f pacenotes-deploy -
Lock it to the script. Append the PUBLIC key to the server user’s
~/.ssh/authorized_keyson this host, prefixed with the script’s ABSOLUTE path — generate the line rather than typing the path, since a home directory is not always under/home(on this host it is/srv). As the server user, from the directory holdingpacenotes-deploy.pub:bash -c 'printf "command=\"%s/.local/bin/pacenotes-update\",restrict %s\n" \ "$HOME" "$(cat pacenotes-deploy.pub)" >> ~/.ssh/authorized_keys'which appends a line like
command="/srv/.local/bin/pacenotes-update",restrict ssh-ed25519 AAAA… pacenotes-deploy. (bash -cbecause the login shell may be fish, whose syntax differs.)sshd runs
command=through the user’s LOGIN shell, so whatever is there must be a path that exists exactly as written: a wrong one fails the deploy job at once with exit code 127 and “Unknown command” (fish) or “No such file or directory” (bash). The script’s own#!/usr/bin/env bashmakes the login shell irrelevant after that.command=replaces whatever the client asks to run with the script, and hands the client’s text over only asSSH_ORIGINAL_COMMAND, which the script accepts solely as anX.Y.Zversion.restrictturns off port forwarding, agent forwarding and the terminal. A stolen copy of this key can redeploy a version already in the registry, and nothing more. -
GitHub, in the repository’s Settings → Environments →
production(create it; this is also where a required reviewer can be added to hold every deploy for approval):Secret Value DEPLOY_SSH_KEYthe PRIVATE key file’s contents DEPLOY_KNOWN_HOSTSoutput of ssh-keyscan -p <port> <host>— checked strictlyDEPLOY_HOSTthe host’s public name or address DEPLOY_PORT<port>(this host’s sshd)DEPLOY_USERthe user whose Quadlet runs pacenotes -
Set the installer’s default server as a REPOSITORY variable (not a secret, and NOT in the
productionenvironment)PACENOTES_DEFAULT_SERVER: Settings → Secrets and variables → Actions → Variables → Repository variables. The release job refuses to build without it.An environment’s variables reach only jobs that name that environment, and the installer job deliberately does not name
production— doing so would expose the deploy key to a job that runs third-party actions and every crate’s build script, and put installer releases behind any approval rule meant for server deploys. The value is compiled into a public.exeanyway, so nothing about it needs protecting. The manual debug build reads the same repository variable, and without it silently falls back to.env.example’s local address. -
After the first release, open the package under the GitHub profile’s Packages tab and confirm it says Private. GHCR makes a new package private, and nothing in the workflow changes that; the installer
.exeis the only public artifact.
Releasing is then:
git tag v/2.1.0
git push <github remote> v/2.1.0
The three jobs run in parallel except deploy, which waits for the image.
A red deploy job means one of: the host was unreachable or its key did not
match DEPLOY_KNOWN_HOSTS; the pull was refused (the host’s GHCR token); or
the new container never became healthy — in which case the script has
ALREADY put the previous image back, and the job log ends with the
container’s last lines.
2. Publishing packages
# 1. drop the zip in the right folder
cp KamilFilipek.zip ~/pacenotes/data/rbr/packages/public/codriver/polish/male/
# 2. publish
podman exec pacenotes pacenotes-server manifest gen --data /data
That is the whole of publishing: no restart, no release, no rebuild. The
server picks the new manifest up on its next request. (A restart would also
regenerate it — entrypoint.sh runs manifest gen on every start — but it
is not needed, and publishing content never deploys code.)
Read the warnings. manifest gen prints one line per package and then
its complaints: mis-filed levels, a sidecar with no zip, a dependency that
does not exist, a name that needs URL-encoding. It is the only place those
mistakes are easy to see.
ERROR: lines are worse than warnings: that package was withheld (a
sidecar that does not parse, a zip outside an access directory). Everything
else still publishes, and the command exits 2 so a script cannot mistake
it for a clean run; on container start the entrypoint prints a banner for
it. Exit 1 means nothing was written and the previous manifest is still
being served.
Optional metadata (sidecars)
Next to <name>.zip, an optional <name>.toml:
display_name = "Kamil Filipek" # renames this package in the UI
description = "Calm, precise delivery"
version = "3.0.0"
exclusive_group = "pacenote-config" # only one of a group installs
requires = ["public/language/polish"] # "directory/name"
Nothing about position goes in here — the folders decide game, access
directory, category and tree. An unknown key is an error that withholds the
package, so a sidecar left over from an older layout (category,
tree_path, game) never publishes something wrong.
The sidecar is the only source. What is not in a sidecar is not in the
manifest: delete a key and it is gone at the next publish. With no
version, a package gets sha- plus the first 8 hex characters of its
sha256 — it changes exactly when the bytes do. The manifest is therefore
reproducible from the zips, sidecars and schemas alone.
Because removing metadata is now possible, manifest gen will not do it
silently: if a run would drop a value the current manifest carries, it
refuses, writes nothing and names each value. Re-run with
--accept-metadata-loss when the removal is intended.
Upgrading from a server older than 2026-10-25 — do both, in this order, BEFORE restarting the unit onto the new version:
-
Give every game its install schema. Without it the new server publishes nothing for that game:
cp schemas/rbr.toml ~/pacenotes/data/rbr/schema.toml -
Older servers carried metadata forward from the previous manifest, so yours may hold values that exist in no sidecar. Move them into sidecars and review what it wrote:
podman exec pacenotes pacenotes-server manifest migrate-to-sidecars --data /dataIt only adds keys a sidecar lacks (never overwrites), never pins the old
0.0.0default, and is safe to re-run.
Forgetting either is not an outage: manifest gen refuses to write a
manifest that would take a live game off the server (missing schema) or
drop metadata, so the old catalogue keeps being served and the container log
says why. --accept-unpublish / --accept-metadata-loss override when that
is really what you want.
Message of the day
echo "Server is up. 24 packages available." > ~/pacenotes/data/motd/en.txt
Served at GET /motd?lang=en, with en as the fallback for any language
that has no file. No file at all is a 404, which the installer treats exactly
like being offline — so skipping this costs the user nothing.
Conflict reports
When two packages a user selected write the same file, the installer lets
the later one win, carries on, and tells the server
(POST /report/conflict). Only you can fix such a catalogue — give the
packages an exclusive_group, or move the shared file into one of them —
so these reports exist to reach you:
- They land in
~/pacenotes/data/reports/conflicts-YYYY-MM-DD.jsonl, one JSON line each. manifest gensummarises the last 30 days as warnings, one per pair of packages, so you see them at the moment you publish.
What a report contains, if a user asks: the game, the installer’s version, and for each shared file the file’s path inside the game folder and the packages that wrote it. Nothing else — no access key, no computer name, no path on the user’s machine, and the server does not store the sender’s IP address (it keeps it in memory only, to rate-limit).
The endpoint takes no key, so it is limited per client (10 reports an hour)
and overall (600 an hour). Behind Caddy every request arrives from Caddy’s
address, so the server must be told to believe Caddy’s X-Forwarded-For
header — and only Caddy’s:
podman network inspect internal --format '{{range .Subnets}}{{.Subnet}}{{end}}'
# -> 10.0.0.0/24 ; then in pacenotes.config.env:
PACENOTES_TRUSTED_PROXIES=10.0.0.0/24
Leave it empty and nothing breaks: the header is ignored, and all clients simply share one bucket.
3. Access keys
public/ needs no key. Every other directory needs a credential that was
granted it. The server stores only sha256(key), so a key is shown once,
at mint time, and cannot be recovered afterwards.
# a directory for paid content, then a key that may see it
mkdir -p ~/pacenotes/data/rbr/packages/premium/codriver
podman exec pacenotes pacenotes-server token mint \
--label "Jan (patron)" --grant premium --expires-days 365
podman exec pacenotes pacenotes-server token list
podman exec pacenotes pacenotes-server token grant <token-or-hash> beta
podman exec pacenotes pacenotes-server token ungrant <token-or-hash> beta
podman exec pacenotes pacenotes-server token revoke <token-or-hash>
--grantis repeatable; a key with no grants is valid and sees onlypublic.token listprints a short hash, which is what the other commands accept in place of the key itself.publiccannot be granted (it is already universal) and the CLI refuses.ungrantandrevoketake effect on the next request: grants are read per request, never cached.
GET /packages never returns 401/403. A caller with a bad, expired or
revoked key silently sees the public listing, so nobody can discover
directory names by probing. Downloads are where 401/403 appear.
4. Testing the installer against it
The installer only needs a base URL:
https://pacenotes.example.comonce Caddy is up, orhttp://<host-ip>:8080if you addPublishPort=8080:8080topacenotes.containerfor a quick local test. Do not leave it published: the server speaks plain HTTP on purpose and expects Caddy to do TLS.
Useful checks from a workstation:
curl -s https://pacenotes.example.com/health
curl -s https://pacenotes.example.com/packages?game=rbr | head -c 600; echo
curl -s "https://pacenotes.example.com/motd?lang=en"
curl -sI https://pacenotes.example.com/download/rbr/public/language/polish
curl -sI -H "Authorization: Bearer pcn_…" \
https://pacenotes.example.com/download/rbr/premium/codriver/polish/male/Someone
Note the download URL shape: /download/{game}/{directory}/{name}, where
{name} is the package’s path under the access directory and must be
percent-encoded (style/Numeric%20Standard).
5. Operating
| Task | Command |
|---|---|
| Deploy a release | push a v/X.Y.Z tag (§1.5), or by hand ~/.local/bin/pacenotes-update X.Y.Z |
| Roll back by hand | podman tag localhost/pacenotes-server:previous localhost/pacenotes-server:current && systemctl --user restart pacenotes |
| What is running | podman image inspect localhost/pacenotes-server:current --format '{{index .Labels "org.opencontainers.image.version"}}' |
| Logs | journalctl --user -u pacenotes -f |
| Health | podman healthcheck run pacenotes |
| Publish content | podman exec pacenotes pacenotes-server manifest gen --data /data |
| Back up | ~/pacenotes/data — auth.db above all, and the packages + sidecars (your content; nothing else has a copy). manifest.json is regenerated from them |
Restart=on-failure brings a crashed server back onto the same image; a
restart never changes the version.
Troubleshooting
manifest gen warns that a category is at the top level. The
access-directory level is missing. Move the category folders under public/
(or another directory) and restart.
A download 404s that should work. Either the manifest is stale (run
manifest gen) or the name needs percent-encoding. {game}, {directory}
and {name} are manifest lookup keys, never paths, so anything not listed —
including every traversal attempt — is a 404 by design.
A download 401s with a key that should work. token list shows whether
it is revoked or expired, and which directories it was granted. 401 means the
key itself was rejected; a live key with no grant for that directory gets 404,
exactly like a package that does not exist (there is no 403).
The deploy job fails with “Host key verification failed”.
DEPLOY_KNOWN_HOSTS does not hold this host’s current key — refresh it with
ssh-keyscan -p <port> <host>. Never switch strict checking off to get past it.
pacenotes-update cannot pull. The token in
PACENOTES_REGISTRY_AUTHFILE expired or lacks read:packages; log in again
(§1.2). podman pull --authfile … <image>:<version> reproduces it by hand.
A deploy rolled back. The new image did not pass its healthcheck within
90 s. :current already points at the previous image again; the job log ends
with the failed container’s last log lines. Build and test the fix, then tag
a new release.
Building the image on this host (only for testing; releases are built in
CI). This host has 3 GB, so the Containerfile defaults to
CARGO_BUILD_JOBS=1 — a parallel build OOM-kills rustc, which podman reports
as layers “vanishing” (identifier is not a container, layer not known).
The same symptom appears when another unit runs podman image prune -f --external during the build, since that deletes concurrent builds’
intermediate containers. Build in a separate storage root:
podman --root ~/.cache/pacenotes-build/storage \
--runroot ~/.cache/pacenotes-build/run \
build --network host -f deploy/Containerfile -t localhost/pacenotes-server:test .
podman unshare rm -rf ~/.cache/pacenotes-build # plain rm cannot: subuid-owned
Architecture
Nine diagrams of the Pacenotes system: two entity–relationship models, UML activity diagrams for the installer and for the server, and SysML block, internal-block and requirement diagrams.
They were drawn by reading the code, not a design document: every box,
column and status can be traced to a file named in the code map.
Where a rule is documented in a CLAUDE.md, the diagram follows it, because
a diagram that quietly disagrees with the rules is worse than no diagram.
| Diagram | Notation | Answers | Chapter |
|---|---|---|---|
| ER-1 | ER | What the server stores, and how a key binds to a package directory | The data model |
| ER-2 | ER | What one run leaves inside <game folder>\.pacenotes | The data model |
| A1 | UML activity | One run end to end: nine pages, three routes | One installer run |
| A2 | UML activity | What scene 6 does inside the engine | One installer run |
| S1 | UML activity | How one download request is answered, and where access is decided | The server |
| S2 | UML activity | Publishing: what manifest gen refuses to do | The server |
| SY-1 | SysML BDD | The blocks, their parts and their constraints | The system in SysML |
| SY-2 | SysML IBD | Deployment: parts, connections, protocols, stores | The system in SysML |
| SY-3 | SysML requirements | Each requirement, what satisfies it, what verifies it | The system in SysML |
How they are made
Each diagram is a D2 source in docs/diagrams/,
rendered to SVG by docs/build.sh every time the site is built, locally and
in CI. Each source names its own layout engine in its d2-config vars:
dagre for the activity flows, ELK where tables and containers need it. They
render light only, because an image in a page follows the system’s colour
scheme rather than the book’s theme. The SVGs are never committed: what is published is
always drawn from the sources in the same commit. A source that does not
parse fails the build rather than publishing a stale picture.
Change a diagram when the structure it shows changes — a new endpoint, module, page or stored field. Nothing regenerates the content by itself.
Notation
| Element | Means |
|---|---|
| Solid arrow | control flow, or an association |
| Dashed arrow | a logical reference with no database constraint, or a compile-time dependency — the label says which |
| Filled diamond | composition: the whole owns the part |
| Diamond shape | a decision, with the guards on its outgoing edges |
| Oval | a final answer: an HTTP status, or a refusal that writes nothing |
| Filled dot | where an activity starts or ends |
| Dashed box titled “for each …” | an expansion region: the actions inside repeat |
| Colour | who acts (wizard, worker, engine, ledger, server, store); each diagram’s caption names them |
Code map
| Concern | Files |
|---|---|
| Protocol types | shared/src/ |
| Wizard, pages, routes | installer/src/wizard/, installer/res/installer.rc |
| Tree and choice models | installer/src/tree/, installer/src/choices/ |
| Worker thread and its messages | installer/src/worker/ |
| Plan, pre-flight, execute | installer/src/engine/ |
| Restore points | installer/src/restore/, installer/src/ledger/ |
| Cache | installer/src/cache/, installer/src/api/ |
| HTTP | installer/src/http/ (WinHTTP) |
| Game detection, the running game | installer/src/gamedir/, installer/src/game_process.rs |
| Install schema | schemas/rbr.toml, installer/src/schema.rs, installer/build/schema_table.rs |
| The game’s own step | installer/src/install/ |
| Router and handlers | server/src/routes.rs |
| Access decision | server/src/auth/ |
| Catalogue and publishing | server/src/store/ |
| Conflict reports | server/src/reports/ |
| Credential CLI | server/src/admin.rs |
| Auth schema | server/migrations/0001_init.sql, server/src/db.rs |
| Deployment | deploy/ |
What the diagrams deliberately leave out
- Sequence diagrams. The interesting ordering is inside one thread (the engine) or one request (the access decision); an activity diagram shows both without inventing lifelines.
- A state machine. The wizard is a property sheet with three routes, not
a stateful protocol. The ledger’s
installing → installedis the only real state, and it is on ER-2. - A class diagram.
shared/is a handful of structs; ER-1 shows them with the keys that matter, and the API reference shows the rest. - Topology beyond SY-2. The Quadlet, the container and the Caddy block are one host; a second picture would repeat SY-2 with hostnames.
The data model
There are two entity–relationship models because the system has two stores that never share a database: the server’s data root and auth database, and the installer’s state inside the game folder. Nothing passes between them but one identity and one hash.
The identity that travels
| Concept | Where it lives | Built by |
|---|---|---|
game / directory / name | server manifest, cache index, ledger, download URL | PackageInfo::download_path() |
sha256 of the zip | server manifest, the cached payload’s file name, ledger entry | manifest gen, verified by the installer |
| case-folded path | ledger file keys only | ledger::fold() |
name is a path under the access directory — codriver/polish/male/Kamil —
and its segments are the installer’s tree: category, then levels, then
the leaf. It is a lookup key and never joined onto a filesystem path;
shared::valid_name is the single check on both sides.
ER-1: the server
Authentication (libSQL, ./data/auth.db)
| Entity | Key | Notes |
|---|---|---|
TOKENS | token_hash | sha256 of the raw key; expires_at NULL means never; revoked 0 or 1 |
GRANTS | (token_hash, directory) | one row per directory; public is never granted |
GRANTS.directory is a logical foreign key: it names a directory in the
catalogue, and no database constraint can check it, because the catalogue is
files on disk. That is a choice, not an omission — the server resolves the
name by manifest lookup on every request, which is also what makes a
traversal impossible.
Catalogue (./data)
| Entity | Key | Notes |
|---|---|---|
GAME | id | a folder; motd and reports are reserved and never games |
ACCESS_DIRECTORY | (game, name) | packages/<directory>/: the unit of access, not the UI category |
PACKAGE | (game, directory, name) | version, size, sha256, updated_at, and the optional display name and exclusive group |
SIDECAR | the same key | <name>.toml beside the zip: the only source of that metadata |
PACKAGE_REQUIRES | (from, to) | the flattened requires[], resolved within one game |
SCHEMA | game | <game>/schema.toml, hashed over its raw bytes |
SCHEMA_CATEGORY | (game, id) | select one or any; install_root a token, never a path |
MOTD | lang | motd/<lang>.txt, en fallback, no auth |
CONFLICT_RECORD | day + line | append-only JSONL: diagnostics, never input to the catalogue |
MANIFEST (derived) | — | data/manifest.json, generated from the rows above |
The protocol documents are drawn too, because they are what crosses the
wire and what the installer parses: Manifest, GameSchema, PackageInfo,
KeyStatus, ConflictReport and ApiError, one module each in shared/src/.
ER-2: the installer
| Entity | Key | Notes |
|---|---|---|
CACHE_INDEX | — | cache.json; Missing, Unreadable and Loaded are three distinct states |
CACHE_ENTRY | game/directory/name | a snapshot of PackageInfo, so the tree works offline |
CACHE_PAYLOAD | sha256 | <sha256>.zip: content addressing gives dedupe, integrity and rollback |
LEDGER | — | installed.json, written atomically (temp file, then rename) |
LEDGER_ENTRY | game/directory/name | state is installed, or installing (written ahead) |
LEDGER_FILE | (entry, path_folded) | the path as written, the hash we wrote, and prior |
RESTORE_POINT | UTC stamp | restore/<stamp>/ holding ledger.json and files/ |
SAVED_FILE | (point, path) | the original bytes, copied only when they differ |
LEDGER_SNAPSHOT | point | the ledger before the run: what lets a restore also remove later work |
RUN_LOG | stamp | logs/install-<stamp>.log, one per run, newest five kept |
Two columns matter more than the rest:
prior—absent,identicalorbackup. It tells a later uninstall whether to delete the file, leave it (it already is the original), or copy the saved original back. Without it, skipping byte-identical files would silently turn a file the user already had into one the installer owns and later deletes. An identical file is not even written: its hash is taken when it is compared.state: installing— written before extraction, so a run that dies halfway leaves files the next run recognises as its own, instead of taking them for pristine originals and backing them up.
Where the two meet
server PACKAGE ──download──▶ CACHE_ENTRY ──install──▶ LEDGER_ENTRY
(game, dir, name) (same key) (same key)
sha256 ──────────────────▶ CACHE_PAYLOAD ───────────▶ per-file sha256
A package is fetched once per game folder and installed from the cache, which is why installing and restoring work with the server unreachable.
One installer run
The pages
| Scene | Page | What it decides | Source |
|---|---|---|---|
| 1 | IDD_S1_LANGUAGE | the UI language; defaults to Windows’ own | wizard/scene/language.rs |
| 2 | IDD_S2_MOTD | the message of the day, and an optional access key | wizard/scene/message_of_the_day.rs, api/ |
| 3 | IDD_S3_FOLDER | the game folder, which game it is (its marker .exe), and the route | wizard/scene/game_folder.rs, game_folder_routes.rs, gamedir/ |
| 4 | IDD_S4_DOWNLOAD | what to have on this computer: catalogue, cache and ledger as one tree | wizard/scene/download.rs, tree/ |
| 4b | IDD_S4B_FETCH | the transfers: cache only, nothing is installed | wizard/scene/fetching.rs, package_download.rs |
| 5 | IDD_S5_CONFIG | one pick per choice category, plus the optional extras | wizard/scene/configuration.rs, choices/, schema.rs |
| 6 | IDD_S6_INSTALL | the run itself | wizard/scene/installation.rs, engine/ |
| 7 | IDD_S7_SUMMARY | what changed, and where the log is | wizard/scene/summary.rs |
| — | IDD_RESTORE | restore points: preview, then carry out | wizard/scene/restore.rs, restore/ |
Three routes, which part at scene 3 and never fork again:
| Route | Path | Reaches the network? |
|---|---|---|
| Download / Update | 4 → 4b → 5 → 6 → 7 | yes: the catalogue and the downloads |
| Configure / Install | 5 → 6 → 7 | no |
| Restore / Uninstall | restore → 6 → 7 | no |
That split is why there is no separate “offline mode”: Configure is the Download route with the two transfer pages left out.
Decisions on the diagram worth reading twice:
- Scene 4’s ticks are what the user wants to HAVE; scene 5’s picks are what to USE. So downloading happens before the questions, and the install page works from the cache alone.
- Scene 5 opens only when every question has something to pick from: a ticked package of each choice category whose payload is in the cache. Configure/Install checks on scene 3 and stays there; Download checks after 4b and goes back to scene 4, with what never arrived unticked.
- An unknown schema hash blocks downloading, and only for that game. An installer that is behind may still reconfigure, uninstall and restore what is already on the machine: taking a working setup away from someone for not updating would be the worst possible answer.
Cancel is the sheet’s button, not a route: honoured on every page before scene 6 and disabled there, because from then on every phase writes to the game folder and the engine has no stop between files.
Scene 6: the engine
The order is the safety argument, not a style:
- Plan what to uninstall (dropped, superseded by a pick or an
exclusive_group, half-written, or changed) and what to install. Dependencies are checked against the selection, not the ledger: anything installed and unselected is removed a few lines later. - Pre-flight everything that can fail — payload hash, write probe, free space, the game running from this folder, every file list — while the folder is untouched.
- Open the restore point, holding the ledger as it is now.
- Uninstall first, always. Several packages ship the same paths, so installing before uninstalling would delete what was just written.
- Per package, with its zip’s directory read once: classify each file’s
prior, write the ledger entry asinstalling, extract through a temp file and a rename, then mark itinstalledwith each file’s hash. A file that already holds the incoming bytes is hashed and left as it is. - Run the game’s own configuration step, then adopt the hashes of the files it edited — otherwise the next uninstall would read our own edit as somebody else’s and leave the file behind.
- Report overlapping files to the server; a failed report is a log line.
The engine is pure std and has to stay testable off Windows, so the two
facts it cannot measure — free space, and whether the game is running from
this folder — are passed in by the UI thread as HostFacts.
The server
One download request
The whole access model is one table:
| Situation | Answer |
|---|---|
the public directory | the package, or 404 |
| a protected directory, no live key | 401, whether or not the package exists |
| a protected directory, a live key without the grant | 404, the same as “no such package” |
| a protected directory, a live key with the grant | the package, or 404 |
There is no 403: “exists, but not for you” is the enumeration signal this
avoids. /packages is the exception that proves the rule: it never answers
4xx, filters silently to the public and granted directories, and falls back
to the public listing when the database is down. A key is validated with
/whoami, not with /packages.
The URL parts (game, directory, name) are manifest lookup keys,
never path fragments: only a manifest hit yields
data/<game>/packages/<directory>/<name>.zip. That is what prevents
traversal structurally, and what makes the {*name} wildcard safe.
Two smaller decisions the diagram shows: an Authorization value that
cannot be a minted key is turned away before any query, so garbage stays
cheap; and a file whose size disagrees with the manifest is refused with 503
rather than streamed — the client would fail its own sha256 check anyway.
Publishing
Publishing is copying a zip into place and running one command. The command is strict on purpose:
- no
schema.toml→ the game publishes nothing (an error, not a warning); - a package in a category the schema does not declare → skipped, with a warning;
- a sidecar with an unknown key → that package is withheld, the rest still publishes, and the run exits 2;
- metadata the current manifest carries would disappear → refused, exit 1,
nothing written, unless
--accept-metadata-loss; - a game that is live now would vanish because its schema is missing →
refused, exit 1, unless
--accept-unpublish.
The manifest is a pure function of zips, sidecars and schemas: nothing is inherited from the previous one, so deleting a sidecar key removes it at the next publish. The two guards exist because that purity is also what lets an accidental upgrade empty a live catalogue.
manifest gen also reads the last 30 days of conflict reports and turns
them into per-pair warnings: a user’s bad day becomes something the operator
can fix in the catalogue.
The system in SysML
Blocks
| Block | Responsibility | Constraint |
|---|---|---|
| Installer | the Windows GUI: nine pages, one worker thread, the install engine | .exe under 700 kB; no tokio, reqwest, rustls, hyper or GUI crate; only DLLs Windows ships |
| Package Server | catalogue, auth, downloads, conflict reports, the admin CLI | one static binary; TLS is the reverse proxy’s job |
| Shared Protocol | the wire types both sides compile against | serde only: no I/O, no async, no HTTP |
| Package Catalogue | zips, sidecars, schemas, the generated manifest, the MOTD, reports | one folder per game under --data |
| Auth Database | keys and their grants | libSQL; raw keys are never stored |
| Game Installation | the game folder a run targets | the only place anything is written |
The composition is deliberate: shared/ is the only code the two binaries
have in common, and it holds no behaviour that could grow a runtime the
installer cannot afford.
Deployment and interfaces
| From | To | Carries |
|---|---|---|
| Player | Installer | choices; the one thing it sends anywhere is an anonymous conflict report |
| Installer | Caddy | HTTPS: GET /packages, /download/..., /motd, /whoami; POST /report/conflict |
| Caddy | Server | plain HTTP on :8080; TLS ends at the proxy |
| Server | Catalogue | manifest.json and the zip streams |
| Server | Auth DB | SELECT ... FROM tokens LEFT JOIN grants |
| Installer | Game Installation | the extracted overlay, and the cache, ledger, restore points and logs in .pacenotes |
| Operator | Catalogue, Auth DB | copy a zip and sidecar, manifest gen; token mint, grant, revoke |
The installer’s HTTPS is WinHTTP, so the TLS handshake is Schannel’s: that is why the binary ships no TLS crate, and why the server can speak plain HTTP behind the proxy. Redirects are followed by the installer itself, never by WinHTTP, so the access key never leaves the origin it was meant for.
Requirements
| Id | Requirement | Satisfied by | Verified by |
|---|---|---|---|
| R-SIZE | the .exe stays under 700 kB and imports only DLLs Windows ships | Installer | installer/tests/Test-BinarySize.ps1 |
| R-TLS | HTTPS through WinHTTP and Schannel; no TLS, async or GUI crate | Installer / http | installer/tests/Test-PublicEndpoint.ps1 |
| R-REDIRECT | redirects followed by the installer; the key never leaves its origin | Installer / http | installer/tests/Test-Redirects.ps1 |
| R-OVERLAY | a package is a game-root overlay; its content is never interpreted | Installer / engine | installer/tests/applying_a_selection.rs |
| R-LOCAL | nothing is written outside the game folder | Installer / cache + ledger | installer/src/cache/tests.rs, installer/src/ledger/tests.rs |
| R-OFFLINE | install and restore work from the cache | Installer / engine + restore | installer/tests/restoring_a_game_folder.rs |
| R-SCHEMA | an unknown schema hash blocks downloading for that game only | Installer / schema | installer/src/schema/tests.rs |
| R-AUTH | bearer keys stored as sha256, grants per directory, no tiers | Server / auth | the decide table in server/src/auth/access_decision.rs |
| R-ENUM | statuses never enumerate; there is no 403 | Server / routes | server/tests/access_matrix.rs |
| R-PATH | URL parts are lookup keys, never paths | Server / store | server/tests/package_download.rs |
| R-PURE | the manifest is a pure function of zips, sidecars and schemas | Server / store | server/tests/publishing.rs |
| R-REPORT | reports are unauthenticated, capped, validated and rate limited | Server / reports | server/tests/conflict_reports.rs |
Pacenotes — project overview (root CLAUDE.md)
Rust rewrite of a C#/WinForms + Supabase installer for RBR (Richard Burns Rally) pacenote packages. Two deliverables:
installer/— Windows-only GUI installer. HARD constraint: final.exe< 700 kB. Seeinstaller/CLAUDE.mdbefore touching it. (Was 500 kB until 2026-10-25, when the install engine landed, and 600 kB until 2026-10-31, when it stopped needing the Visual C++ redistributable, and 620 kB until 2026-11-01, when the build reached it exactly and the maintainer raised it. The rules it produced did not move.)server/— package/auth server. Single static binary, libSQL (SQLite) for auth, packages stored as flat files on disk. Seeserver/CLAUDE.md.shared/— protocol types ONLY (serde structs). No I/O, no async, no HTTP. Both sides depend on it; nothing else is shared.
Non-negotiable architecture rules
- The installer must NOT pull in tokio, reqwest, rustls, native-tls, openssl, hyper, or any GUI framework crate. HTTPS goes through WinHTTP (OS Schannel). This is what keeps the binary small, and it stays true whatever the gate’s number is — the forbidden crates are forbidden because of what they drag in, not because of 100 kB of slack.
- The server is a normal async stack (axum + tokio). Do not try to make the server share HTTP code with the installer.
shared/stays dependency-minimal:serdeonly. If you’re about to add a dependency toshared, stop and reconsider.- Auth model: bearer tokens (credentials). Server stores
sha256(token), never the raw token. NO TIERS: packages live in named directories (packages/<directory>/<name>.zip); each credential holds grants, each grant naming one directory it may list/download; a credential can hold any number of grants. Thepublicdirectory is visible to everyone. A grant covers its whole directory — per-package access = a package in its own directory.
Install model (fixed mechanism, declarative metadata)
-
Every package zip is a game-root overlay: extracting it at the game directory puts every file in its final place. This is the ONLY install mechanism. There are NO instruction/script files inside packages and the installer must never execute or interpret package content. Rationale: a compromised server must not gain arbitrary installer behavior; an interpreter costs size budget; all real packages already conform.
-
Where a package SITS on the server is how it is shown.
PackageInfo.nameis the package’s path under its access directory, without the.zip:packages/public/codriver/polish/male/Kamil.zip ^dir ^------------- name -------^Its segments ARE the UI tree: first =
category, middle = tree levels, last = the package.sharedderives all three (category(),tree_levels(),leaf()); neither side stores them separately. Organising the files on disk organises the installer’s Download tree, and sidecars carry no positional metadata at all.- Access
directoryis auth ONLY and is a separate level of the layout, so the same category can appear underpublic/and a private directory. nameis a LOOKUP KEY, never joined onto a filesystem path. Validate it withshared::valid_nameon both sides: no\, no./..segment, no empty segment. See the security invariants inserver/CLAUDE.md.- Deriving the tree from storage keeps one source of truth and makes the operator’s own folders meaningful. It does NOT weaken the rule that the installer never interprets package CONTENT: nothing inside a zip is read for meaning, and a compromised server still cannot gain arbitrary installer behavior.
- Access
-
The rest stays declarative metadata in the manifest, never files in the zip:
display_name(optional): renames the LEAF only; the levels above it come fromname.exclusive_group(optional): packages sharing a group overwrite the same files (e.g. the pacenote-config variants); the UI radio-selects within a group and Apply uninstalls the previous member first.requires(optional): list of “directory/name” deps the UI enforces (e.g. voice pack requires its language pack). The name half may itself contain/, so a ref splits on the FIRST separator only.
-
Nothing is written outside the game folder. Every trace of this installer — cache, ledger, backups, restore points, run logs — lives in that folder’s
.pacenotes\. Deleting the game removes all of it, and a folder copied to another drive or machine arrives complete. -
Cache: downloads land in
<game dir>\.pacenotes\cache\<sha256>.zipplus acache.jsonindex. Sha-keyed: integrity, dedupe, and an older version stays reachable after the index moves on — which is what lets a restore roll an update back. Installing is cache → game dir, so it works offline. Two game folders no longer share one cache: that is the price of the rule above, and it is paid deliberately. -
Ledger: each game dir gets
.pacenotes\installed.jsonrecording, per installed package, the exact files written. Uninstall = delete ledgered files. Apply = diff (selected vs ledger) → uninstall removed, install added. The location is the SAME for every game: a path inside the plugin tree would be RBR-only, and the plugin tree is written by other tools. -
Backup: before overwriting a file NOT owned by the ledger, copy it once to
.pacenotes\backup\(preserving relative path). Restore = copy back. A file whose bytes already match what is about to be written is NOT copied: there is nothing to preserve, and a blind copy would duplicate hundreds of MB of identical content on the first run. This replaces the legacy backup/FilipekMod/unpacked directory dance. -
Per-game configuration step: after the install phase, each game may run a step of ITS OWN. There is no shared abstraction and no attempt to make one: the games do not share a file layout, several do not have a plugin system at all, and their installers may have to edit game files directly. RBR’s step modifies the existing Pacenote config (or writes one from a built-in template when there is none); another game’s step will look nothing like it. What is shared is the plumbing — the step reports the files it wrote, and they are ledgered and backed up like any other.
Server layout (one server, every game)
ONE server serves all games. The first level under its data root names the game, and each game owns its own packages tree:
data/rbr/packages/public/codriver/polish/male/Kamil.zip
^game ^dir ^------------ name -----^
data/rbr/schema.toml the game's install schema (no schema = the
game publishes nothing)
data/motd/<lang>.txt message of the day (no game: shown before one
is chosen)
data/reports/ conflict reports from installers
data/manifest.json every game in one document
game therefore comes from the LAYOUT, never from a sidecar — the same rule
as category/tree position. Game ids and access directory names share one
rule, shared::valid_id ([a-z0-9_-]+). manifest gen warns when a level
is mis-filed (a category where an access directory belongs, an access
directory or category where a game belongs, a game folder with no
packages/) and when a name needs percent-encoding.
Protocol (client ↔ server)
GET /packages[?game=<id>]— optionalAuthorization: Bearer <token>. Returnsshared::Manifest(JSON) filtered to thepublicdirectory plus the caller’s granted directories (silent filtering; /packages never 4xxs — a bad, expired or revoked key simply gets the public listing, and a DB outage does too). Withoutgamethe document covers EVERY game; with it, just that one. An unknown game is an empty list, not an error.GET /download/{game}/{directory}/{*name}—namespans the remaining segments, because it is the package’s path under its directory (PackageInfo::download_path()builds the whole tail). Same auth rules; streams./data/<game>/packages/<directory>/<name>.zip. The status never reveals what a protected directory holds: no live key on a non-public directory → 401 WHETHER OR NOT the package exists; a live key without that grant, or no such package → 404. There is no 403 (it said “exists, but not for you”). A name may contain characters that need percent-encoding (e.g.style/Numeric Standard); the client must encode.GET /whoami—Authorization: Bearer <token>. 200shared::KeyStatus { expires_at }for a live key, 401 for anything else (none, malformed, unknown, expired, revoked). This — not/packages— is how a client validates a key.GET /motd?lang=xx—text/plainfromdata/motd/<lang>.txt, falling back toen, 404 when there is none (treat exactly like being offline). No auth.langis restricted to[a-z]{2,8}server-side.GET /health— 200, no auth.POST /report/conflict— no auth, bodyshared::ConflictReport{game, installer, conflicts: [{file, packages: ["dir/name", …]}]}, the packages of each file in INSTALL ORDER (last = winner). An installer reports that selected packages wrote the same file. It does NOT block the user: the later package wins and the operator, who is the only one who can fix such a catalogue, gets told. Carries no user identity and no local paths; the server stamps the time and stores no IP. Unauthenticated write, so: 8 KiB cap (413), validated fields with bad entries dropped (400 if none survive), rate-limited per client + globally (429 withRetry-After; the client is taken fromX-Forwarded-Foronly when the peer is a--trusted-proxy), append-only JSONL, empty 202 on success. Seeserver/TASKS.mdS10.- The manifest carries each game’s INSTALL SCHEMA (
Manifest.schemas,shared::GameSchema): the game’s categories, which ask for one pick, and where each installs — plus the SHA-256 of the schema file’s RAW BYTES. It replaces the earlier “manifest version”: what an installer can fall behind on is the shape of a game’s catalogue, not the package list. The installer uses it as a COMPATIBILITY CHECK ONLY (its behaviour and localized labels stay compiled in;install_rootis a token, never a path, so a server can never steer where files go). A hash the installer does not know blocks DOWNLOADING for that game only — reconfiguring and uninstalling keep working. The canonical files areschemas/<game>.toml(byte-exact,.gitattributes-text); installers embed them. Seeserver/TASKS.mdS11 anddeploy/README.mdfor the rollout order.
Any protocol change = change shared/ first, then both sides in the same PR.
Reference material
legacy/ contains the original C# sources (DownloadManager.cs,
InstallationForm.cs, Program.cs). Port behavior users depend on
(overlay layout, ini generation, restore-to-pristine), not mechanisms the
new install model supersedes (backup/FilipekMod/unpacked staging dirs,
oldest-zip restore). installer/CLAUDE.md says which is which.
Documentation
docs/is an mdBook that CI publishes to GitHub Pages (.github/workflows/docs.yml);docs/build.shbuilds the same site locally. SeeDEVELOPMENT.md§3.1.- A document lives where it belongs in the repository. Its book chapter is
one
includeline at the same relative path underdocs/src/, so links between documents work in both places; add that line and adocs/src/SUMMARY.mdentry for a new document. Links to source files are pointed at GitHub bytools/mdbook-repo-links. - Diagrams are D2 sources in
docs/diagrams/, rendered to SVG at build time; the SVGs are never committed. - The API reference is rustdoc with private items. The installer’s is
built with
PACENOTES_ENV_FILE=.env.example: rustdoc prints the default server, and a published page must never carry a real deployment’s address. - No Python anywhere in the project, documentation tooling included.
Workspace layout / build
- Root
cargo buildbuildsshared,serverandtools/mdbook-repo-links(Linux OK, used in CI). - A new workspace member must also be
COPY’d indeploy/Containerfile: cargo reads every member’s manifest even to build only the server. CI loads the workspace from exactly what that file copies; a release found the gap first, at v/2.0.1. installer/builds only on Windows MSVC target and is excluded from the workspace default members on purpose. Do not “fix” that.- Check binary size after every installer change:
Windows:
installer/tests/Test-BinarySize.ps1(fails > 700 kB, or on a DLL Windows does not ship).
Working conventions for Claude Code sessions
- Follow the task lists:
TASKS.md(root, ordered milestones), theninstaller/TASKS.mdandserver/TASKS.md. Mark items done as you go, and record work you discover mid-task as a SUB-POINT of that task. requirements.mdsits beside each of those lists and says what a task must satisfy, including which verification applies..claude/session.mdsays what is being worked on right now. Task lists carry no prose: a rule belongs in aCLAUDE.md, behaviour inDESIGN.md, the story of a change inCHANGELOG.md.- Prefer std over dependencies everywhere, but especially in
installer/. - Limit amount of
usestatements. Prefer the full path at the point of use (std::path::PathBuf,pacenotes_shared::Manifest,anyhow::bail!(...)), most importantly in struct/enum definitions and function signatures. Where a trait method is needed, call it fully qualified (<sha2::Sha256 as sha2::Digest>::new()). This keeps every identifier’s origin visible at the call site. - Windows paths in install logic use
\\separators intentionally (RBR expects them in .ini content). Do not “normalize” .ini file contents. - No unwrap() in server request paths; map errors to proper HTTP statuses.
- Installer: no unwrap()/expect()/panic! outside tests — clippy denies them
(see
installer/CLAUDE.md, “Error handling”); a failure shows a MessageBox and the installer recovers or exits gracefully.
installer/CLAUDE.md — Windows GUI installer
Windows-only (x86_64-pc-windows-msvc). HARD RELEASE GATE: final .exe < 700 kB.
Run tests/Test-BinarySize.ps1 after every change that touches dependencies or adds
noticeable code. If size regresses, fix it before continuing.
The gate was 500 kB until 2026-10-25, when the install engine (I7) took the binary to 546 kB, and 600 kB until 2026-10-31, when the installer stopped needing the Visual C++ redistributable (I10, below). Each raise measured the alternatives first — see “The size budget” below. It was 620 kB until 2026-11-01, when scene 5’s readiness check (I9) landed the build on exactly 620 kB and the maintainer raised it to 700; that raise came with no measurement, and none of the levers below was spent. Treat 700 as the same kind of hard line 500 was: the answer to “we are over” is to find the bytes, and the levers below are still on the table. The next build that wants more should have to argue for it as these did.
Why the binary stays small — do not break these
- HTTPS = WinHTTP (
Win32_Networking_WinHttp). The OS provides TLS (Schannel). Never add reqwest/hyper/rustls/native-tls/openssl. VERIFIED against the real endpoint: handshake, chain and SNI all work through WinHTTP. Keep it verified — every other test talks plain HTTP to loopback, sotests/Test-PublicEndpoint.ps1is the ONLY thing that takes theWINHTTP_FLAG_SECUREbranch. Run it when the deployment changes. - UI = raw Win32 dialogs from
res/installer.rctemplates, compiled byembed-resourcein build.rs, driven byDialogBoxParamW/CreateDialogParamWand a dlgproc. Never add a GUI framework crate. - Zip = miniz_oxide + a hand-rolled zip central-directory parser in
src/zip.rs(~150 lines). Do not add thezipcrate. - No tokio. Concurrency = one
std::threadworker +PostMessageWto the UI thread (customWM_APP+nmessages carry progress %, status string index, completion). This replaces the C# BackgroundWorker pattern. - serde_json is allowed but on notice (also used for cache.json + ledger):
if the final binary is over budget, the fallback is a minimal hand-rolled
JSON parser/writer. Keep all JSON usage isolated in
api.rs+cache.rsledger.rsso the swap stays cheap.
- Prefer
windows-sys(thin declarations) overwindows(heavy). - No TOML in the installer: sidecar metadata is server-side only; the installer sees it pre-merged in the manifest JSON.
The size budget
Measured with cargo bloat --release --target x86_64-pc-windows-msvc --crates (install it with cargo install cargo-bloat). At 546 kB, on the
day the gate moved:
std | ~195 kB |
| this crate | 89 kB |
the icon, in .rsrc | 83 kB |
| serde_core + serde_json + memchr + itoa | 43 kB |
| miniz_oxide | 2.7 kB |
Two findings worth keeping, because both are counter-intuitive:
- miniz_oxide costs almost nothing. It was budgeted at ~40 kB and is under 3. Inflate is a small algorithm; it was the engine around it that cost.
- One
{:.1}on anf64costs ~16 kB. Formatting a float links Rust’s whole float-to-decimal machinery.size.rstherefore does its one decimal digit with integer arithmetic, and nothing in this crate formats a float. Check before adding one.
The second raise, 600 → 620 kB (2026-10-31), and its argument. The
exe imported VCRUNTIME140.dll, the Visual C++ redistributable, which
Windows does not ship: on a PC without it, Windows refuses to start the
installer with an unlocalized “VCRUNTIME140.dll was not found”, before a
single line of ours runs. Measured on the same commit:
| Build | Size | Needs the redistributable |
|---|---|---|
| dynamic CRT (as it was) | 595.5 kB | yes |
| fully static CRT | 692.5 kB | no |
| hybrid CRT (static VC runtime, Universal CRT from Windows) | 617.5 kB | no |
The icon lever (below) would have fitted the hybrid build under 600; the
maintainer chose to keep the icon and raise the gate to 620 instead. The
hybrid CRT is +crt-static in .cargo/config.toml plus two link
arguments from build/runtime_link.rs, which also REFUSES to build
without +crt-static — a RUSTFLAGS variable replaces the config file’s
flags, so CI and Run-AllTests.ps1 deny warnings through clippy instead.
The same file sets [build] target, so even a plain cargo test keeps
those flags off host tools: without it they reached the proc macros,
whose rebuild collided with the copies an editor’s rust-analyzer holds open.
Test-BinarySize.ps1 fails the build if the exe imports anything Windows
does not ship.
Levers still unspent, in order of cheapness, if the gate is threatened again:
- The icon, ~85 kB for a single 256x256 PNG. Windows downscales that one image for every size it needs, so the taskbar and list views look worse than they should AND it is the single largest object in the binary. Re-encoding losslessly saves only 4 kB — the image is that dense — but shipping 16/24/32/48/64/128 instead saves 39 kB and looks better everywhere except Explorer’s extra-large view. Dropping to 64 as the largest saves 66 kB and does look worse.
- serde_json, ~43 kB plus the derive code in this crate. The planned
fallback (point 5 above): a minimal hand-rolled parser/writer for the
three documents we read. Keep JSON isolated in
api.rs,cache.rsandledger.rsso this stays a contained change. - The two
--selftest-*developer flags, ~8 kB, behind a cargo feature — but CI drives--selftest-httpagainst a live server, so the shipped binary and the tested one would stop being the same thing. Last resort.
Install model (see root CLAUDE.md for rationale)
- Packages are game-root overlay zips. ONE install mechanism: extract into game dir, record files in the ledger. NEVER interpret or execute anything found inside a package.
- A package is identified by
game+directory+name. One server serves every game, so"directory/name"is NOT unique: two games can both holdpublic/style/Numeric Standard. Every key the installer stores — the cache index, the ledger, any ref it writes down — must carry the game, or one game’s package silently shadows another’s.PackageInfo::download_path()produces exactly that triple,game/directory/name, and is also the tail of the download URL; prefer it over assembling keys by hand. EVERYTHING this installer writes lives under the game folder it was pointed at, in that folder’s.pacenotes\: the ledger, the backups, the restore points, the run logs AND the download cache. Nothing goes into the user’s profile or anywhere else. Deleting the game takes every trace of this tool with it, and a folder moved to another drive brings its downloads and its way back along with it.
The cache lived in %LOCALAPPDATA% until 2026-10-27, which bought one
thing — two game folders sharing a download — at the price of state nobody
remembers to clean up. The trade was made the other way: disk is cheap,
and a second copy is what keeps the first folder’s restore points working
after the second folder is deleted.
-
Cache (
cache.rs):<game dir>\.pacenotes\cache\<sha256>.zip— the payloads, keyed by manifest sha (integrity+dedupe)cache.json— index: (game, directory, name) → {sha256, version, size, updated_at, display_name, exclusive_group, requires} — a snapshot of PackageInfo, so the Download tree works fully OFFLINE. Category and the tree levels are NOT stored: they are derived fromname.- Download = fetch → sha256 verify → move into cache. Re-download only when manifest sha differs from cached sha.
- Swept when it opens (a route taken on scene 3): leftover
.partfiles, and payloads named by NONE of the index, the ledger and every restore point (restore::payloads_in_use). NOT “what the index does not name”: the index keeps one version per key, and the version an update replaced is exactly what a rollback needs. If any of those records cannot be read, no payload is deleted. A--cacheoverride is never swept, since it may serve other folders.
-
Ledger (
ledger.rs): per game dir,.pacenotes\installed.json..pacenotes\rather than somewhere in the plugin tree, because that path is RBR-only and is written to by other tools. Written atomically (temp + rename) AFTER each successful package install/uninstall, not once at the end."<game>/<directory>/<name>": { "sha256": …, "version": …, "exclusive_group": …, "state": "installed" | "installing", "files": { "plugins/pacenote/pacenote.ini": { // key: case-folded "path": "Plugins/Pacenote/PaceNote.ini", // as actually written "sha256": …, // what WE wrote "prior": "backup" | "identical" | "absent" } } }Every part of that shape pays for itself, and a plain list of paths cannot do any of it:
-
case-folded keys, original spelling kept. NTFS ignores case, so
Plugins/Pacenoteandplugins/pacenoteare one file. Comparing keys byte-for-byte answers “does another package still own this?” wrongly, and the wrong answer deletes a file another package needs. -
per-file
sha256of what we wrote. Steam’s verify, the RSF installer, a plugin update or the user’s own editor can replace a file after we installed it. Uninstall deletes a file only when it still matches; otherwise it is left alone and logged. Someone else’s work is not ours to remove. -
prior— what was there before us.backup: the original is in a restore point.identical: the bytes already matched, so nothing was copied, and the original can be recovered from the cached payload.absent: there was no file, so uninstall deletes it. Without this, the byte-identical skip quietly turns files the user already had into files the installer owns and later deletes.An
identicalfile is not written at all, and its hash is taken when it is compared. Uninstalling aninstalledentry needs nothing from the cache for it either: the file has just been hashed against what was written, which IS the original. Only a write-ahead (installing) entry, whose files were never hashed, recovers an identical original from the payload, and that only while the payload is cached — a user can empty the cache by hand. The rule that holds regardless: if the original cannot be put back, the file is left alone and logged — never deleted. -
state: "installing", written BEFORE extracting. A run that dies mid-extract leaves half-written files that are in no ledger entry — so the next run would treat them as the user’s own and back them up as the pristine originals. The write-ahead entry makes them recognisably ours. -
Paths are
/-separated, aszip::entriesreturns them: a ledger must read the same on any machine.
-
-
Backup / restore points: a run that replaces anything writes a dated restore point,
.pacenotes\restore\<timestamp>\holding the files it replaced plusledger.jsonas it was BEFORE the run. A file whose bytes already match what will replace it is not copied — nothing to preserve, and copying would duplicate hundreds of MB on a first run over an existing manual install; it is recorded asprior: "identical"instead. Restoring to a point is not just copying files back: it also removes what was installed after it, which is what the ledger snapshot is for. SeeDESIGN.md“Restore”. -
Facts the engine cannot measure are INJECTED, never called for. Free disk space and “is the game running” arrive as values the caller measured (
HostFacts), becauseengine.rshas to stay testable off Windows. Anything else that would need Win32 inside the engine belongs on that struct instead. -
Install (
engine.rs): input = the wizard’s result (the three Configuration picks plus the checkedpacenotespackages) + the ledger. Plan = uninstall (ledgered but no longer chosen, including a ledgered member of a category orexclusive_groupthat a new pick supersedes) then install (chosen but not ledgered, or sha changed = update). Enforcerequires. Scene 6 runs the plan, from the CACHE: the download phase is a screen of its own (4b) that happens BEFORE the configuration questions, so the install page never reaches the network except to report a conflict. -
Overlapping files (two selected packages writing the same path): install order decides, the later one wins, and the run log says so. No gate and no refusal — a user must never be blocked by a catalogue mistake they cannot fix. The cost is accepted and known: after uninstalling the winner the file survives (another entry still owns it) holding the winner’s bytes, so the folder drifts from what any one package expects. That is why the conflict is REPORTED to the server (
POST /report/conflict) rather than only logged: the operator is the only one who can fix it, and they cannot fix what they never hear about. -
Exclusivity is enforced in the ENGINE only. A choice category is an implicit group, handled by scene 5 asking for one pick. For
exclusive_group,plan()uninstalls the ledgered member before installing the new one. Neither scene 4 nor scene 5 gates it: two members ticked in one run both install, the later wins, and the overlap rule above reports it. -
Choice categories are per game, and they come from the COMPILED-IN schema —
schema.rs, built bybuild.rsfromschemas/<game>.toml. Not fromshared, where one list drove scene 5 for all four games, and not from the manifest. A category withselect = "one"is a question scene 5 asks; everything else is optional.- The schema says WHICH categories exist and which ask for one pick.
The LABEL and EXPLANATION of each stay in the
.rcstring tables with every other string, mapped by category id inwizard/scene/configuration.rs(category_caption). Shipping UI text through the manifest would mean the server deciding what the installer says, in languages it cannot know;install_rootis a token for the same reason — a server must never be able to steer where files go. - Only a game’s choice categories are MANDATORY on scene 4. An operator
adding an
extras/folder must not turn it into a question every user has to answer. - A game with no choice categories skips scene 5 entirely.
build/schema_table.rsreads those files with a hand-rolled strict reader, not thetomlcrate: three string keys in one table array need no parser, and a schema using anything beyond that fails the BUILD with a line number instead of compiling a table that disagrees with the server’s reading of the same file. Nothing parses TOML at runtime.
- The schema says WHICH categories exist and which ask for one pick.
The LABEL and EXPLANATION of each stay in the
-
Install-schema check (replaces the earlier “minimum manifest version”). What an installer can fall behind on is the SHAPE of a game’s catalogue, not the number of packages in it. So
Manifest.schemascarries, per game, the SHA-256 of that game’s schema file’s RAW BYTES, andschema::checkcompares it with the hash of the copy this build embeds. A hash this build does not know means the catalogue may hold categories it cannot place. The rule for what happens then matters as much as the check:- It blocks DOWNLOADING, and only for THAT game. An out-of-date installer may still reconfigure, repair, uninstall and restore what is already on the machine — the user did not break anything by not updating, and taking their existing setup away from them would be the worst possible response. None of those paths consults the server.
- The warning appears when the user tries to download, not at startup.
- An offline run never shows it: the check runs only on a manifest that actually came from the server, never on one rebuilt from the cache.
- The hash is of raw bytes, so
.gitattributesmarksschemas/*.toml-text. A checkout that rewrote the line endings would make every installer built there reject every server. - A revision that is NOT a real incompatibility (a fixed comment, a
reordering) goes in
schema::ALSO_ACCEPTEDwith a line saying why it does not matter — otherwise people learn to click through the prompt. See S1 inDESIGN-REVIEW.mdand S11 inserver/TASKS.md.
Module map
main.rs— entry point, common-controls init, the developer flags (--server,--cache), launches the wizard.--cacheoverrides<game dir>\.pacenotes\cachefor development, to repeat a download that already happened once.crash.rs— the PANIC HOOK. A GUI binary withpanic = "abort"has nowhere to print, so without it a panic is a vanished window; it writespanicked at <file>:<line>into the run log (or beside the exe before a folder is known) and shows it once, localized, offering to open the file. It found the Configure→Back→Download abort in one run.--selftest-panicandtests/Test-CrashReport.ps1keep it honest: a crash handler nothing exercises is one that fails when it is needed.selftest/—--selftest-http, the WinHTTP check thattests/Test-HttpClient.ps1drives,--selftest-download,--selftest-report, which posts a fabricated conflict report, and--selftest-panic.elevation.rs— P11’s restart as administrator: theRestarta new instance resumes from (language, folder, key; it opens on scene 3), the UAC launch, and whether this process is already elevated. Its command line is tested against Windows’ own splitter (CommandLineToArgvW), since a folder with spaces or a trailing backslash must come back exactly.game_process.rs— whether the game is running FROM THIS FOLDER (P12), by the process’s own path, so a copy installed elsewhere does not block; unreadable counts as running. FeedsHostFacts::game_running, which the engine’s pre-flight refuses on. Paths are compared by hand:std::fs::canonicalizecost 3.5 kB.single_instance.rs— one copy per user. A restarted instance waits for the one that launched it to close; the check closes the handle it gets for a name already taken, or that handle would keep the mutex alive.run_header.rs— the run log’s header: build, start, server, and the Windows build fromRtlGetVersion.package_download.rs— fetch a package into the cache, verified by sha256, retried after a failure a retry could fix (HttpError::is_transient, or a payload damaged in transit) with a cancellable wait. Scene 4b and--selftest-downloadboth go throughdownload_retrying, so the test drives the code the user runs.wizard/— the UI. Layout perDESIGN.md: a comctl32 PropertySheet in wizard mode (sheet.rs), one page per scene (scene/), withWizardState(state.rs) shared across pages and what every page does alike inpage/. Control ids mirrorres/installer.rcincontrol_ids.rs. Nine pages and THREE ROUTES, which part on scene 3 (scene/game_folder_routes.rs): Download/Update (4 → 4b fetches → 5 asks → 6 installs), Configure/Install (straight to 5, then 6, with no server and no transfers) and Restore/Uninstall (scene/restore.rs, then 6). Scenes 4b and 6 are the same screen at two moments, so they share their control ids andscene/progress_page.rs; onlyRoutedecides what is skipped. Before changing the sheet, read Implementation notes inDESIGN.md: the scene title, the per-scene button labels and the title bar all work around something the OS does not give.- The restore page is registered LAST in the sheet, because page order is what “Next” means: between the questions and the progress page, Install walked onto it. It is reached and left by id.
- INVARIANT:
WizardState.tree_items(node index →HTREEITEM) is assigned wherevertreeis, with a zero handle for a node the control does not hold. SysTreeView32 sends an item-changed notification withlParam = 0, indistinguishable from node 0, before it holds anything of ours, and acting on it aborted the Configure route. So the two are sized together, the refresh is driven off the handle table, and a notification is validated by itshItem. - INVARIANT: whatever replaces
treewith anything but scene 4’s own catalogue clearscatalogue_loaded, so scene 4’s next visit fetches and rebuilds the model and the control together. The Configure route once left them disagreeing: Download then passed its gate with nothing ticked, and the next visit ignored every tick. - Scene 5 is entered only through
configuration::has_something_to_pick(on scene 3 for Configure, after 4b for Download): every choice category needs a checked package the cache HOLDS (Cache::holds).
ui/— the small Win32 vocabulary the pages are written in: control text, combo boxes, list boxes, the three modal shapes, the folder picker, andpaint_forward_button, the light green of the way forward. A themed push button ignores the colours its parent offers, so it is drawn whole fromNM_CUSTOMDRAW: Next and Finish through a subclass of the sheet (wizard/sheet.rs, hooked atPSCB_INITIALIZED), since they are the sheet’s controls, and Download/Update from scene 3’s own page.choices.rs— scene 5’s model: the three single-select lists and the no-choice extras, derived from what scene 4 committed. Pure.tree/— scene 4’s model: manifest + cache → rows, states, checks. Pure.runlog.rs— the run log: in memory for the pane, flushed line by line to<game dir>\.pacenotes\logs\install-<stamp>.log, newest five kept. The UI thread owns it, as it owns the controls. It is attached once scene 3 settles a folder, replaying what was said before, because the log belongs with the install it describes. The FILE opens with a header (build, start, server, Windows build;run_header.rsin the bin) and times every line in UTC; the pane shows neither.utc_time.rsis the one place a date is formatted — do not hand-roll another.size.rs— byte counts as a person reads them (“41.6 MB”), in binary units so they match Explorer.worker.rs— the one background thread. A worker NEVER touches a control; it posts aMessageand the UI thread acts on it.gamedir.rs— which games exist, their dropdown labels, and the marker executable that identifies each one’s folder, plusdetect()which drives the wizard’s game dropdown. Pure std, so the folder check is testable without a desktop. A game folder is NOT “any directory that exists”; see the note under Testing.http/— WinHTTP wrapper:get/get_streamingwith a progress callback, andpost_jsonfor the one thing the installer sends (/report/conflict), over one request path. Synchronous; always called from the worker thread.getholds the whole body in memory, so it refuses one over 8 MiB (MAX_BUFFERED_BYTES), counting rather than trustingContent-Length, which a chunked reply does not send. A small document is the server’s promise, and the cap is what holds when it is broken. Anything big — a package — goes throughget_streaminginto a file.- REDIRECTS ARE FOLLOWED HERE, NEVER BY WINHTTP, and the bearer token
never leaves the origin the caller asked for. Measured 2026-10-30:
left to itself WinHTTP follows a 3xx and re-sends
Authorizationto the new origin while this code never sees the hop — so a 302 from a misconfigured CDN or a hijacked record would hand over the user’s access key silently. Each request setsWINHTTP_OPTION_REDIRECT_POLICY_NEVERandsendloops itself: at mostMAX_REDIRECTShops, no https→http downgrade, and the token attached only while scheme, host and port are all unchanged. Do not “simplify” this back into WinHTTP’s own redirect handling.tests/Test-Redirects.ps1is what keeps it honest.
api.rs— the endpoints:/packages,/motd,/whoami,/health,/download/...into the cache, andPOST /report/conflict; JSON is isolated here./whoamiis the ONLY way to validate an access key, since/packagesanswers the public listing for a key it does not accept. A 401 means wrong, unknown, expired or revoked; anything else, a 200 we cannot parse included, is “could not ask”, which perDESIGN.mdcarries on with the key unvalidated.schema.rs— the install schemas this build embeds (generated bybuild.rs), the per-game choice categories read off them, andcheck(), the one comparison made against the server’s schema hash.cache.rs— cache dir layout, cache.json read/write, sha verification.ledger.rs— installed.json read/write (atomic), file ownership queries.engine.rs— plan + execute the install; backup; restore points; uses zip.rs.uninstall_packageandinstall_packagearepub(crate)becauserestore/drives the same steps;install_packagetakes anInstallingdescriptor rather than looking the package up, since the cache index holds only the newest version of each key and a restore wants an older one.restore/— the other half of a restore point: reading them back (point.rs), planning what going to one would mean (plan.rs, pure, table-tested), and carrying that out through the engine’s steps (execute.rs). “Uninstall everything” is a restore to an empty state, not a separate path. Three rules it exists to keep: the plan is a value computed before anything moves, so the preview and the work cannot disagree; nothing is removed unless what replaces it is ready; and the ledger written afterwards keeps the snapshot’s per-fileprior, because that — not the bytes — is what stops a later uninstall deleting a file the user always had.sha256.rs— small std-only sha256 (~100 lines). (Nosha2crate.)zip.rs— zip reader (central directory + miniz_oxide inflate). The legacy zip WRITER for backups is no longer needed (backups are plain file copies) — do not write one. Reading several files from one package goes throughzip::Archive, which reads the central directory ONCE: looking each file up by walking it again made an install of n files cost n² entry parses, 8.5 s of a 12 s install at 3 000 files.install.rs— dispatch to the chosen game’s own configuration step, plus one module per game that has one (install/rbr.rs). There is NO shared abstraction over these on purpose: the games share no file layout, some have no plugin system, and a step may have to edit game files directly. The only contract is that a step reports the files it wrote, so they are ledgered and backed up like extracted ones.i18n/— string table lookup (localization via .rc STRINGTABLE), the shippedLanguagelist, and the OS-UI-language default for scene 1.
Porting map (C# legacy/DownloadManager.cs → new model)
| C# | Fate |
|---|---|
InitializeSupabase[/(privateKey)] | replaced: api.rs + bearer access key |
ListAllFiles + recursion | replaced: flat manifest fetch |
DownloadFile/saveFile | replaced: api.rs download → cache (sha compare supersedes the updated_at mtime check) |
ExtractZipFile | ported: zip.rs extract, same dir-entry handling |
CreateRBRConfiguration | SUPERSEDED, measured against the live catalogue: the style packages now ship the config\pacenotes\*.ini files it generated, and the voice packages ship a complete PaceNote.ini. RBR’s step EDITS language and sounds in that file and preserves the rest; it writes the file from a template only when absent. Regenerating it would discard the author’s muteReplayPacenotes, comments and any newer key |
backupRBRConfiguration | superseded by engine backup (per-file, first-touch). The C# stream bug is moot |
unpack/install/uninstall RBRConfiguration | superseded by cache + ledger + engine |
restoreRBRBackup (oldest-zip rule) | superseded by backup-tree restore |
Behavior users depend on and MUST survive: overlay layout lands files in the right places; a pristine-restore path exists; config style choice produces the same inis; missing Pacenote plugin dir → localized “install the plugin first” error.
Error handling
- No
unwrap,expectorpanic!outside tests, and clippy enforces it:lib.rsandmain.rsdenyclippy::unwrap_used,expect_usedandpanicwhen not compiling tests. A panic in a GUI built withpanic = "abort"is a vanished window. The one deliberate panic,--selftest-panic, carries anallowsaying why; build scripts are a separate crate and fail the BUILD with a message on purpose. - Every user-reachable failure →
MessageBoxWwith a localized string, then either recover or clean exit. No silent catch-and-continue (the C# did this; don’t copy it). - A failed install must leave the ledger truthful: ledger updates are per-file batch per package, written after that package’s action succeeds.
Size audit workflow
tests/Test-BinarySize.ps1— fails build over 700 kB (release gate), or on an import Windows does not ship.- When investigating:
cargo bloat --release --target x86_64-pc-windows-msvc. - Budget sketch: WinHTTP+dialog skeleton ~120-180 kB; miniz ~40 kB; serde+serde_json ~80-120 kB; cache/ledger/engine ~40 kB. Headroom is real but finite — check after each milestone.
Tests
Rust unit tests live beside the module they test (ST-007 of the
coding-standards skill): #[cfg(test)] mod tests at the bottom of the file,
or <module>/tests.rs once they outgrow it, as src/gamedir/tests.rs and
src/tree/tests.rs do. Cargo’s tests/ holds only scenario tests that drive
the public API across modules, named after the scenario, plus the PowerShell
suites. Read tests/README.md before adding one.
pwsh -File tests/Run-AllTests.ps1 # everything
pwsh -File tests/Run-AllTests.ps1 -SkipUi # headless: logic + size gate
src/gamedir/tests.rs— cross-platformcargo test. Game-folder identification.tests/Test-ReleaseVersion.ps1— a release’s version comes from itsv/X.Y.Ztag and ONLY from there:build/release_version.rsreadsPACENOTES_RELEASE_TAGand feeds the user agent, the conflict report and all four VERSIONINFO fields (via a generated header), and fails the build on any other shape; no tag means0.0.0-dev. The test builds from tags and reads the version back out of the binary. Never type a version intoCargo.tomlor the.rc— nothing reads them.tests/Test-BinarySize.ps1— the 700 kB release gate, and the imports check.tests/Test-HttpClient.ps1— builds and runs a realpacenotes-server, then drives--selftest-httpagainst it: /health, anonymous vs authenticated /packages, downloads, 401, 404, an unreachable port, and that the server’s access log names this build’s user agent. WinHTTP cannot be unit tested, so this is the only thing that proveshttp.rsworks. Needs no desktop, so CI runs it.tests/ui/Test-UiLayout.ps1— AUTOMATED TEST that drives the real .exe through user32.dll. Run it in BOTH languages (-Language 0/-Language 1) after ANY change tores/installer.rc,ui.rsori18n.rs. The Polish pass is what proves the per-language STRINGTABLE lookup and the UTF-8 diacritics. It also makes a folder this account cannot write (a deny rule set through .NET, so no admin rights are needed), checks scene 3 stops with the right message and leaves no trace, and launches with the restart arguments while the first instance still runs. It answers the restart question NO (Decline-Modal): yes would raise a real UAC prompt. And the keyboard (Send-Key,Get-FocusedControlIdinWin32Ui.psm1): where focus starts on scenes 1–3, scene 3’s Tab order, and that Enter in the folder field takes Download/Update. Only the dialog manager decides those, soSend-KeyPOSTS the key into the wizard’s queue, where the sheet’s loop hands it to the dialog manager; a message SENT to a control would skip it. Not real input (keybd_event): that goes to whatever window is in front, Windows’ foreground lock would not reliably let a background test put the wizard there, and a missed key lands in another application.tests/ui/Get-UiScreenshot.ps1— AGENT/DEVELOPER AID. Writes PNGs so a human or an agent can SEE the layout. Asserts nothing, so never gate on it. Use it whenever you change geometry: assertions cannot reveal an overlap, and a tab-header Z-order bug already slipped past a fully green run once.tests/ui/Win32Ui.psm1— shared helpers. Its comments record the traps (GetWindowTextdoes not cross process boundaries;TCM_SETCURSELdoes not notify the parent;-likeeats[Game]prefixes).
The GUI tests need Windows with an interactive desktop session. Everything else
runs anywhere, which is the point of the lib/bin split: pure modules live in
the lib target (src/lib.rs) and the Windows-only bin (src/main.rs) owns
http/ui/i18n, so cargo test never needs Win32.
CI (.github/workflows/ci.yml, installer job) runs fmt, clippy, cargo test, the release-version test and the size gate — everything except the GUI tests.
The installer is excluded from the Cargo workspace, so the server job’s
fmt/clippy do NOT reach it; that job is the only thing linting this crate.
Run-AllTests.ps1 runs the same set plus the GUI tests, so a green local run
means a green CI run.
Two rules for writing tests here
- No arbitrary inputs. A fixture must stand for a real situation the user
can be in. A game folder is the folder holding THAT GAME’s executable, so
the fixture is a directory containing
RichardBurnsRally_SSE.exe— not$env:TEMP. An earlier suite used a scratch directory and passed happily whilevalidate_diraccepted any path that existed. - Assert against the source of truth, not a copy. The UI test reads the
expected strings out of
res/installer.rc, so it catches a caption bound to the wrong id or a missing translation. Duplicating the strings into the test would only prove two hand-written copies agree.
If a field needs a test, that is because it requires data from a precise context. Work out what that context is and build it; do not assert that the field merely holds something.
What still belongs under test, and is not yet
Pure-logic modules that are still scaffolding: zip.rs, sha256.rs,
cache.rs index logic, ledger.rs, engine.rs planning, install.rs ini
generation. Keep Win32 out of them and inject base paths/writers so they stay
testable. Engine planning (diff + exclusive groups + requires) is THE most
test-worthy logic in the installer — it gets exhaustive table tests at I7.
server/CLAUDE.md — package + auth server
Single static binary. Runs anywhere (deployed on Linux). No size constraints — normal async Rust. axum + tokio + libSQL. Packages are flat files on disk.
Access model (no tiers)
Packages are grouped into directories — both an access boundary and a
literal on-disk level: ./data/<game>/packages/<directory>/<name>.zip.
public/is visible to everyone, credential or not.- Every other directory is visible only to credentials granted it.
- One credential can hold grants for any number of directories.
- A grant covers the whole directory (list + download of everything in it). Per-package access = put the package in its own directory.
- Access directory ≠ UI category. The category is the FIRST segment of
name, i.e. the folder below the access directory, and has NO effect on auth. A grant is per directory, whatever categories sit inside it. - Access directory ≠ game either. The game is the level ABOVE
packages/, so the same directory name may exist under several games and a grant for it covers that name in every game.
Data model
-
Auth DB: libSQL. Local embedded file
./data/auth.dbby default; switching to remote Turso must require ONLY changing--db-url/PACENOTES_DB_URL(keep all queries portable, no local-file assumptions). Tables:tokens+grants(token_hash, directory). See migrations/0001_init.sql. -
Data root (
--data, default./data): ONE server serves every game.data/<game>/schema.toml install schema (S11) data/<game>/packages/<directory>/<name>.zip packages data/motd/<lang>.txt message of the day data/manifest.json every game, one document data/auth.db credentials (libSQL)Every level means exactly one thing: game, then the literal
packages/, then the access directory, then the package’sname— its path below the access directory, nested as deep as the operator likes. Nothing positional comes from a sidecar, which is whygameis NOT a sidecar key. -
Install schema (
<game>/schema.toml, S11):[[category]]entries withid(a category folder),select(one|any) andinstall_root(a token likegame, never a path);deny_unknown_fields, ids unique andvalid_id. Publishing is STRICT: no schema → the game publishes nothing (error); unparsable → same; a package in an undeclared category → skipped (warning). The manifest embeds each schema with the SHA-256 of its RAW bytes — the value installers compare. The server bundles the repository’sschemas/<game>.toml(store::BUNDLED_SCHEMAS) and warns when a data root’s copy differs: installers of that release would refuse it.motdandreportsare reserved data-root names, never games. -
Packages: game-root overlay zips, dumb payloads, never introspected by the server.
-
Sidecar metadata (optional):
<name>.tomlnext to its zip at whatever depth it lives, authored by hand. Position is NOT in here:display_name = "Numeric standard calls" # renames the LEAF only exclusive_group = "pacenote-config" requires = ["public/language/polish"] # "directory/name" refs description = "..." version = "2.0.0" # else "sha-<8 hex of sha256>"The sidecar is the ONLY source of these values (D5): nothing is inherited from a previous manifest, so the manifest is a pure function of zips + sidecars + schemas, and deleting a key removes it.
manifest genrefuses (exit 1, writes nothing) when a run would DROP a value the current manifest carries, unless--accept-metadata-loss;manifest migrate-to-sidecarsmoves manifest-only values into sidecars. Unknown keys are an ERROR (deny_unknown_fields): an old sidecar carryingcategory,tree_pathorgamewithholds ITS package instead of publishing silently-wrong metadata. The rest of the catalogue still publishes; the run exits 2. -
manifest.json (
shared::Manifest) at./data/manifest.jsonis whatGET /packagesserves. It is GENERATED:manifest genwalks<data>/<game>/packages/<directory>/**/*.zip, computes size/sha256/updated_at, merges each sidecar, validatesrequiresrefs, and writes the file ATOMICALLY (temp file + rename in the data dir). Hand-edits get overwritten — edit sidecars instead. Exit status: 0 all published; 2 written but something withheld (ERROR:lines); 1 could not run (nothing written, the previous manifest stays). Publishing = drop zip (+ optional sidecar) → runmanifest gen. No upload API in v1. -
Manifest is loaded at startup and hot-reloaded on file mtime change (checked lazily per request, cached with an RwLock — no inotify dep).
-
Game ids and directory names:
[a-z0-9_-]+(shared::valid_id), enforced bymanifest genand re-checked on manifest load.publicis reserved for anonymous access and must never appear ingrants. -
Package names:
shared::valid_name— a/-separated path with no\, no./..segment and no empty segment. Rejected or non-UTF-8 names are skipped with a warning rather than entering the manifest. -
manifest genwarns (does not fail) on the mis-filings that are easy to make: a category name where an access directory belongs, a category or access directory where a game belongs, a game folder withoutpackages/, an unknown game id, a sidecar with no zip, and a name that needs percent-encoding in a URL.
Security invariants
- Raw tokens are NEVER stored or logged. Store lowercase hex sha256 only.
Mint format:
pcn_+ 32 random bytes base64url (admin CLI prints it once). - Download path:
{game},{directory}and{name}from the URL are LOOKUP KEYS into the manifest, never joined into a filesystem path directly. Only a manifest hit yieldsdata/<game>/packages/<directory>/<name>.zip. This structurally prevents path traversal and is what makes a nested, wildcard{*name}safe — keep it that way. /motd’slangis the ONE path-ish input that cannot be validated by lookup, because it names a file no manifest lists. It is therefore restricted to[a-z]{2,8}before it touches the filesystem.- Statuses never enumerate: on a non-public directory, no live key → 401
WHETHER OR NOT the package exists; a live key without the grant, or no
such package → 404 — the same answer. No 403. A syntactically impossible
game/directory/name → 404 (nothing could exist). Body =
shared::ApiErrorJSON; a 401 carriesWWW-Authenticate: Bearer./packagesnever 4xxs — it silently filters to public + granted directories. - Token rows decode FAIL-CLOSED (an undecodable column is an error, never a
default). Decode from
row.get_value: libsql 0.6’s typedrow.get::<i64>PANICS on a type mismatch instead of returning an error. - Public packages never touch the auth DB, and
/packagesfalls back to the public listing on a DB error: a database blip must not take the free catalogue down. A bearer value not shaped like a minted key (pcn_+ 43 base64url chars) is rejected before any query. - No panics in request handlers: anyhow at edges →
impl IntoResponseerror type mapping to status + ApiError. - TLS is terminated by Caddy in front (deploy/Caddyfile). The binary listens plain HTTP on localhost/container network. Don’t add TLS crates.
Endpoints
GET /health→ 200 “ok”GET /whoami→ 200shared::KeyStatus { expires_at }for a live key, 401 otherwise. How a client validates a key.GET /packages[?game=<id>]→ shared::Manifest filtered to public + granted directories, and to one game when asked (absent = every game)GET /download/{game}/{directory}/{*name}→ zip stream (tokio fs → axum body), Content-Length from the file handle (503 if it disagrees with the manifest),application/zip, Content-Disposition naming the LEAF with an ASCIIfilenameand an RFC 6266filename*; auth per access model above.{*name}is a wildcard: a name spans every remaining segment.GET /motd?lang=xx→text/plainfromdata/motd/<lang>.txt,enfallback, 404 when there is none, no authPOST /report/conflict(reports.rs) →shared::ConflictReport; 202 empty / 400 / 413 / 429. Rate limit checked BEFORE parsing; body capped byDefaultBodyLimiton the route; nothing echoed; one JSON line per report indata/reports/conflicts-YYYY-MM-DD.jsonlwith the server’sreceived_at, never the client’s address.--trusted-proxy(PACENOTES_TRUSTED_PROXIES) decides whoseX-Forwarded-Forcounts. Requires serving withinto_make_service_with_connect_info::<SocketAddr>().manifest genturns the last 30 days of reports into per-pair warnings.- (v2, not now) POST /admin/* — credential management is a CLI subcommand
CLI (clap)
pacenotes-server serve --listen 127.0.0.1:8080 --data ./data --db-url file:./data/auth.dbpacenotes-server token mint --label "Jan" [--grant premium-voices]... [--expires-days N](repeatable –grant; a token with zero grants is valid but sees only public)pacenotes-server token grant <token-or-hash> <directory>pacenotes-server token ungrant <token-or-hash> <directory>pacenotes-server token revoke <token-or-hash>pacenotes-server token list(shows label, grants, expiry, revoked)pacenotes-server manifest migrate-to-sidecars --data ./data— one-off: copy values that exist only in manifest.json into sidecars (adds missing keys, never overwrites, never pins0.0.0/sha-…; idempotent).pacenotes-server manifest gen --data ./data [--accept-metadata-loss] [--accept-unpublish]— refuses (exit 1, writes nothing) when a game the current manifest serves would vanish because its schema is missing/broken, unless--accept-unpublish: an upgrade that forgotschema.tomlmust not empty the live catalogue. Scan + sidecar merge + validation as described under Data model. Warnings for: sidecar without zip, invalid directory name, top-level zips, danglingrequires, cross-directoryrequirespointing into a NON-public directory from a public package (a public package must not require protected content).
Testing
- Unit:
auth::decide(pure: token row + directory → decision) — exhaustive table test (exists in scaffold; extend, don’t delete). - Unit: sidecar merge +
requiresvalidation inmanifest gen. - Unit, beside each module: install-schema parsing/strictness
(
store/game_schema.rs), and inreports/validation, CIDR +X-Forwarded-Fortrust, the rate limiter andutc_date. Store tests generate throughfixture_tree, whosewrite_packageadds a zip’s category to its game’s schema and which skips the bundled-schema comparison; that comparison has its own tests. - Integration (
tests/<scenario>.rs, sharingtests/common/mod.rs): a real server on an ephemeral port, served WITH connect info likemain.rs, a tempdir data root (two games, public + two protected dirs, schemas) and in-memory libsql. Asserts the {anon, token-no-grants, token-dirA, token-dirA+dirB} × {public, dirA, dirB} matrix for /packages filtering and /download statuses (401 for no live key whether or not the package exists, 404 for no grant), expired/revoked,/whoami, a DB outage (tables dropped mid-run), cache headers/304, schemas in the listing, conflict reports (202/400/413/429, forged and trustedX-Forwarded-For), and percent-encoded traversal → 404. HTTP clients collapse literal../before sending, so traversal tests must encode it.
Conventions
- All config via flags with env fallbacks (PACENOTES_LISTEN, PACENOTES_DB_URL, PACENOTES_DATA_DIR).
- tracing for logs; never log Authorization headers. Every response gets
one INFO line on target
access(method, path+query, status, bytes, ms to the response head, 12-char token-HASH prefix or-/malformed, user agent) — production runs at INFO, whereTraceLayeris silent. - Cache headers:
/packagesprivate, no-store+Vary: Authorization; protected downloadsprivate, no-store; public downloadsno-cache. Every download carriesETag: "<sha256>"and honoursIf-None-Matchwith 304 — AFTER authorization, so a protected validator is never confirmed to a stranger. - Keep handlers thin; logic in
auth.rs/store.rsmodules as they grow. - Sidecar parsing needs a TOML crate (e.g.
toml) — fine, the server has no size budget.
Development: environment, build, test, release
Everything a person needs to get this repository building and to know that
what they changed still works. For what to build next see TASKS.md; for
the rules a change has to respect see the CLAUDE.md files.
1. How the repository is split
| Crate | Builds on | Why |
|---|---|---|
shared/ | any OS | Protocol types only (serde). No I/O. |
server/ | any OS (deployed on Linux) | axum + tokio + libSQL. Normal async stack. |
installer/ | Windows, MSVC target only | Win32 GUI + WinHTTP, under a hard 700 kB gate. |
tools/mdbook-repo-links/ | any OS | The documentation book’s preprocessor (§3.1). |
installer/ is deliberately excluded from the Cargo workspace, so a
plain cargo build at the root builds shared + server and nothing else.
That is why the installer has its own CI job, its own lint run and its own
test runner. Do not “fix” the exclusion.
Practical consequence: two target/ directories, and two places to run
cargo. The root, and installer/.
2. Prerequisites
2.1 Windows (installer work)
-
Rust — https://rustup.rs →
rustup-init.exe, accept the defaults (stable-x86_64-pc-windows-msvc). -
MSVC build tools — rustup offers to install them; otherwise Visual Studio Build Tools with the “Desktop development with C++” workload. Needed for
link.exeand for the resource compiler thatembed-resourcedrives overres/installer.rc. -
PowerShell 7 (
pwsh) — every script underinstaller/tests/is run aspwsh -File …and they invoke each other the same way. Windows PowerShell 5.1 is not enough. -
A desktop session — the GUI tests drive real dialogs. They cannot run over a service account, a container, or an SSH session with no desktop.
-
installer/.env, before the first build — the server the installer talks to by default comes from this file, which git ignores so that no deployment’s address is ever committed. Without itcargo buildandcargo testboth fail.cd installer Copy-Item .env.example .env # http://127.0.0.1:8080, a local serverFor a release build, set
PACENOTES_DEFAULT_SERVERin that file to the public HTTPS address. The release workflows write it from the repository variable of the same name, and fail when that variable is unset.--server <url>still overrides it at run time.
The Windows box also builds pacenotes-server: two of the installer tests
start a real server to talk to. Nothing extra to install for that — but it
means a server-side dependency that breaks on Windows breaks the installer
suite too (this has happened; see §8).
2.2 Linux (server and shared work)
sudo apt update && sudo apt install -y build-essential pkg-config git
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # defaults
source "$HOME/.cargo/env"
2.3 Editor
rust-lang.rust-analyzer, plus ms-vscode-remote.remote-ssh if you work on
both machines. Open the repo locally on Windows for installer work and
over Remote-SSH on Linux for server work; keep them in sync through git.
Editing one checkout over SMB/SSHFS is miserable — rust-analyzer and
target/ over a network mount are slow enough to change how you work.
3. Building
# Linux or Windows — shared + server
cargo build
cargo test
# Windows only — the installer
cd installer
cargo build --target x86_64-pc-windows-msvc # debug
.\tests\Test-BinarySize.ps1 # release + size gate
Test-BinarySize.ps1 is the normal way to make a release build: it builds
and then fails if the .exe is over 700 kB or imports a DLL Windows does not ship. Check the size after every
installer change — the budget is the reason the installer has no HTTP
crate, no TLS crate and no GUI framework.
3.1 The documentation site
docs/ is an mdBook that CI publishes to GitHub Pages
(.github/workflows/docs.yml): every Markdown document in the repository,
the architecture diagrams, and the rustdoc API reference. Build the whole
site locally with
docs/build.sh # Git Bash on Windows, or any shell elsewhere
and open docs/book/index.html. It needs, besides cargo:
- D2 — one self-contained binary from its
releases page. It renders
docs/diagrams/*.d2to SVG. The SVGs are build output and never committed. - mdBook — one binary from its
releases page, or
cargo install mdbook.
CI pins both versions in docs.yml; use the same ones.
How the book is put together, so a change lands in the right place:
- A document lives where it always did. Most chapters under
docs/src/are a single line using mdBook’sincludedirective on the original, at the same relative path (docs/src/installer/DESIGN.mdincludesinstaller/DESIGN.md), so the links between documents work in the book as they do on GitHub. A new document gets such a chapter and an entry indocs/src/SUMMARY.md. - Links to source files cannot be book pages;
tools/mdbook-repo-linkspoints each one at the file on GitHub when the book is built. - The architecture chapters live in
docs/src/architecture/, and their diagrams indocs/diagrams/. Change a diagram when the structure it shows changes. - The API reference is rustdoc with private items. The installer’s half
builds only on Windows; elsewhere
docs/build.shleaves it out unlessINSTALLER_APInames a directory holding it. Its default server address is always.env.example’s: the script setsPACENOTES_ENV_FILE, because rustdoc prints the address and a published page must never carry a real deployment’s. - There is no Python anywhere in this project, the documentation included.
4. Testing
4.1 The installer: one entry point
cd installer
pwsh -File tests\Run-AllTests.ps1 # everything
pwsh -File tests\Run-AllTests.ps1 -SkipUi # headless: steps 1-6 only
Eight steps, in order, and what each one is actually for:
| # | Step | What it proves |
|---|---|---|
| 1 | cargo fmt --check | The installer is outside the workspace, so the root CI job never formats it. |
| 2 | cargo clippy | Same, under -D warnings exactly as CI does. |
| 3 | cargo test | The pure modules: game detection, the two scene models, sha256, the cache, the run log, size formatting. No Windows needed; CI runs these. |
| 4 | Test-ReleaseVersion.ps1 | Builds from a v/X.Y.Z tag and from none, and reads the version back out of the binary: file properties and user agent. Then checks every malformed tag fails the build (§6). |
| 5 | Test-BinarySize.ps1 | The 700 kB release gate, and that the exe needs no Visual C++ redistributable or any other DLL Windows does not ship. Also produces the .exe the next three steps run. |
| 6 | Test-HttpClient.ps1 | Builds and starts a real pacenotes-server, then drives the installer’s --selftest-http against it: health, anonymous vs bearer, 401/403/404, unreachable, a body over the 8 MiB buffering cap (from a loopback listener: the real server never sends one), and --selftest-download through a stand-in that fails it once (retried) or for good (not), and that the server’s access log names this build’s user agent. WinHTTP cannot be unit tested, so this is the only honest check of it. Needs no desktop, so CI runs it. |
| 7 | ui\Test-UiLayout.ps1 | Walks scenes 1-3 against a deliberately dead server (--server http://127.0.0.1:1), once per shipped language, asserting every caption against what res/installer.rc defines. Also a folder this account cannot write (scene 3 must stop and say why), a restart as administrator resuming on scene 3, and the keyboard: where focus starts, scene 3’s Tab order and its Enter, through the dialog manager. |
| 8 | ui\Test-WizardDownload.ps1 | Walks the whole wizard, scenes 1-7, against a live server holding a nested catalogue, with real downloads into a throwaway cache. Asserts both gates, the lists, the summary, the cache on disk and the run log’s header and times. Later passes: Configure, a route switch, Restore, a failing /health, downloads that fail once or for good, and the game running from this folder or another. |
Steps 7 and 8 need an interactive desktop. CI skips them; run them locally before anything that touches the UI.
Two more scripts, neither of them gates:
-
tests\Test-PublicEndpoint.ps1— MANUAL CHECK against the real deployment over HTTPS. It is the only test that takes theWINHTTP_FLAG_SECUREbranch and exercises the OS TLS stack, which is the justification for shipping no TLS crate. Run it when the deployment changes. A red result often says something about the server, not the code. -
tests\ui\Get-UiScreenshot.ps1— AGENT / DEVELOPER AID. Captures a PNG of each scene so a person (or an agent) can look at it. Asserts nothing and always exits 0. Assertions cannot see a clipped label, two overlapping controls or a dead area; a z-order bug once survived a fully green run and was caught only here. Use it whenever you change geometry or add a scene.# scenes 1-5 offline, English then Polish pwsh -File tests\ui\Get-UiScreenshot.ps1 pwsh -File tests\ui\Get-UiScreenshot.ps1 -Language 1 # scenes 6-7 need a real catalogue AND really download it pwsh -File tests\ui\Get-UiScreenshot.ps1 -Scenes 7 ` -Server https://[Replace with IP or domain] -CacheDir $env:TEMP\shotcache
Before you add a test, read installer/tests/README.md. It carries two
rules (no arbitrary inputs; expected text comes from the resource, not from a
copy) and a numbered list of traps in driving Win32 from PowerShell — each
one cost real debugging time, and each is commented at its call site.
4.2 The server and shared
cargo test # unit tests + server/tests/api.rs
cargo clippy --workspace --all-targets
cargo fmt --all -- --check
server/tests/api.rs is the auth matrix: anonymous, a credential with no
grants, one directory, several; plus expired, revoked, ungranted, 404 and
traversal attempts.
5. Running things by hand
5.1 A local server
mkdir -p data/rbr/packages/public/style data/motd
cp something.zip data/rbr/packages/public/style/Descriptive.zip
echo "hello" > data/motd/en.txt
cargo run -p pacenotes-server -- manifest gen --data ./data
cargo run -p pacenotes-server -- serve --listen 127.0.0.1:8080 --data ./data
curl http://127.0.0.1:8080/health # -> ok
--db-url is independent of --data: it defaults to file:./data/auth.db,
relative to wherever you ran the command, so a serve started from the
repository root drops an auth.db there whatever --data says. /data/ is
gitignored for that reason — never commit one, it is a credential store.
The folder tree is the installer’s UI tree — public/ is an access
directory and a level of its own, and the segments below it become category,
sub-levels and leaf. manifest gen prints one line per package and then its
complaints (mis-filed levels, a name that needs percent-encoding, a
dependency that does not exist). Read them. The full layout, access keys and
MOTD are in deploy/README.md.
Credentials, when you need a private directory:
cargo run -p pacenotes-server -- token mint --label "dev" --grant premium
cargo run -p pacenotes-server -- token list
A key is printed once; the server stores only its sha256. The credential
store defaults to ./data/auth.db, and --db-url belongs to token, before
the subcommand (token --db-url file:… mint …) — putting it after mint
is a usage error.
5.2 The installer against that server
cd installer
$exe = "target\x86_64-pc-windows-msvc\release\pacenotes-installer.exe"
& $exe --server http://127.0.0.1:8080 --cache $env:TEMP\pcn-cache
| Flag | For |
|---|---|
--server <url> | Point at a local or staging server. Default is the public HTTPS endpoint. |
--cache <dir> | A throwaway download cache. Use it for anything test-shaped, or you read back whatever a previous run left in your own profile. |
--selftest-http <url> [--token T] [--out FILE] | One real GET through http.rs, reported to a file or a message box. |
--selftest-download <game> [--largest] [--server U] [--token T] [--out F] | Fetch a catalogue and download its smallest (or largest) package: stream, hash, verify, promote, index. |
--selftest-report <game> [--server U] [--out F] | POST a fabricated conflict report through api::report_conflicts. The only thing the installer ever sends, and a real one needs a catalogue where two packages write the same file. |
--selftest-panic [--out F] | Panic on purpose, with the crash window suppressed, to prove the panic hook writes a report naming a source file and line. |
The four --selftest-* flags exist because the paths they cover are the ones
most likely to be wrong against a real server, and walking the GUI to reach
them makes every attempt slow and flaky. Plain HTTP is fine for development;
WinHTTP only negotiates TLS when the URL says https.
Where a run leaves things, when --cache is not given:
<game dir>\.pacenotes\cache\<sha256>.zip verified payloads
<game dir>\.pacenotes\cache\cache.json what each one is
<game dir>\.pacenotes\logs\install-<stamp>.log one log per run
<game dir>\.pacenotes\installed.json the ledger
<game dir>\.pacenotes\backup\, restore\<stamp>\ what was replaced
Everything, under the folder the installer was pointed at. Deleting the
game folder removes every trace of the tool. --cache <dir> overrides the
cache location for development; nothing else can be moved, and nothing is
written to %LOCALAPPDATA% or anywhere else in the profile.
6. Releasing
A release is a pushed tag, and the tag is the only place a version is written. Nothing is bumped by hand:
git tag v/1.2.3
git push origin v/1.2.3
cd-release-on-tag.yml builds from it. The tag reaches the build as
PACENOTES_RELEASE_TAG, and installer/build/release_version.rs turns it
into everything that shows a version: the file properties (all four
VERSIONINFO fields, through a header it writes for res/installer.rc), the
user agent the server logs, and the version a conflict report carries.
- The shape is exactly
v/X.Y.Z, each part 0..=65535 (VERSIONINFO stores 16-bit words). Anything else —v1.2.3,v/1.2,v/1.2.3-rc1— FAILS THE BUILD, so a mistyped tag cannot become a release. - Without the variable a build is a development one,
0.0.0-dev. That is every local build, every CI run and the debug-build workflow, and it is how a dev build is told apart in the server’s access log. installer/Cargo.tomlsaysversion = "0.0.0"and nothing reads it.Test-ReleaseVersion.ps1builds from real tags and reads the version back out of the binary; CI runs it.
| Workflow | Trigger | Result |
|---|---|---|
ci.yml | push / PR to master | shared+server on Linux, installer on Windows (fmt, clippy, tests, release version, size gate, HTTP client) |
cd-release-on-tag.yml | a v/* tag is pushed | Release build, checks the .exe carries the tag’s version, publishes it as the GitHub release of that tag |
cd-debugbuild-manually.yml | workflow_dispatch | Debug .exe + .pdb as a build artifact, no release |
Merging into master publishes nothing; tagging does.
Deploying the server is a separate thing and lives in
deploy/README.md: a Podman Quadlet that builds straight from git, plus a
Caddy site block for TLS.
7. Conventions
- Follow the task lists.
TASKS.md(root milestones) →installer/TASKS.mdandserver/TASKS.md. Tick items as you go. - One commit per task-list item, not per file touched. Say in the message what changed and why, including what you decided not to do.
- Record the installer’s size in
installer/TASKS.mdafter a change that moves it; the table there is the history of the budget. - A protocol change is
shared/first, then both sides, in the same PR. - Prefer std over a dependency everywhere, and especially in
installer/. - Read the
CLAUDE.mdin the directory you are about to change. They carry the rules that are not obvious from the code — the size budget, the “installer never interprets package content” rule, the auth model, and the reason the folder layout on the server is the UI tree.
8. Things that will bite you
The installer suite builds the server, so a Linux-only dependency breaks
Windows. libsql once lost the feature that transitively enabled
libsql-sys/rusqlite, and without it libsql-sys compiles
std::os::unix::ffi::OsStrExt — on Windows. server/Cargo.toml now declares
libsql-sys with rusqlite explicitly to pin that down.
RUSTFLAGS leaks between crates. Run-AllTests.ps1 sets -D warnings
to match CI, and saves/restores it around the run — a stray value forces a
full rebuild of the server’s dependency tree and applies the installer’s lint
policy to a crate with a different one.
cargo test at the root does not test the installer, and
cargo clippy --workspace does not lint it. That is what steps 1-3 of the
runner are for.
The GUI tests are not optional for UI work. They are the only thing that
catches a caption bound to the wrong id, a gate that does not gate, or a
control that never got its text. But they are also not sufficient: run
Get-UiScreenshot.ps1 and actually look at the result.
installer/tests
The installer’s scripted checks live here: the size gate, the release version,
the WinHTTP test and the GUI tests. Rust unit tests do not: they sit beside
the module they test (src/gamedir/tests.rs, src/tree/tests.rs), and this
directory takes Rust tests only when they drive the public API across several
modules, named after the scenario they cover.
Run the lot:
pwsh -File tests/Run-AllTests.ps1 # everything
pwsh -File tests/Run-AllTests.ps1 -SkipUi # headless: logic + size gate only
What is here
| Path | Kind | Needs a desktop? | In CI? |
|---|---|---|---|
cargo test | AUTOMATED TEST — the unit tests in src/, plus applying_a_selection.rs and restoring_a_game_folder.rs: the engine and a restore over a scratch game folder, sharing common/mod.rs | no | yes |
cargo test -- --ignored | MANUAL CHECK — the zip reader against a corpus of real packages (src/zip/fixtures/README.md) | no | no |
Test-ReleaseVersion.ps1 | AUTOMATED TEST — a build carries its v/X.Y.Z tag’s version | no | yes |
Test-BinarySize.ps1 | AUTOMATED TEST — the 700 kB release gate, and no DLL Windows does not ship | no | yes |
Test-HttpClient.ps1 | AUTOMATED TEST — WinHTTP vs a live server | no | yes |
Test-CrashReport.ps1 | AUTOMATED TEST — a panic leaves a usable report | no | yes |
Test-Redirects.ps1 | AUTOMATED TEST — a token never leaves its origin | no | yes |
ui/Test-UiLayout.ps1 | AUTOMATED TEST — walks the wizard, scenes 1-3 | yes | no |
ui/Get-UiScreenshot.ps1 | AGENT / DEVELOPER AID — captures PNGs of scenes 1-7 | yes | no |
ui/Win32Ui.psm1 | SUPPORT MODULE — shared user32.dll helpers | n/a | n/a |
Test-PublicEndpoint.ps1 | MANUAL CHECK — the real server over HTTPS | no | no |
ui/Test-WizardDownload.ps1 | AUTOMATED TEST — scenes 1-7, live catalogue, real downloads | yes | no |
Run-AllTests.ps1 | runner for all of the above, plus fmt + clippy | optional | no |
Run-AllTests.ps1 also runs cargo fmt --check and cargo clippy under
-D warnings, matching the CI installer job. The installer is excluded from
the Cargo workspace, so the root server CI job’s fmt/clippy never touch it —
without these it would go unlinted.
CI skips the GUI tests (they drive real dialogs and need an interactive
desktop session) and Test-PublicEndpoint.ps1 (it depends on a machine
someone else runs).
Test-PublicEndpoint.ps1 earns its place despite not being a gate: every
other test talks plain HTTP to loopback, so it is the ONLY one that takes the
WINHTTP_FLAG_SECURE branch and exercises the Schannel handshake. That path
is the justification for shipping no TLS crate at all, so it needs something
that can actually fail. Run it when the deployment changes.
Three kinds, and each script states which it is in its own .SYNOPSIS:
| Kind | Meaning | Gate CI on it? |
|---|---|---|
| AUTOMATED TEST | Asserts, hermetic, exits non-zero on failure. | Yes |
| AGENT / DEVELOPER AID | Produces artifacts for a person or an agent to look at. Asserts nothing, always exits 0. | No |
| MANUAL CHECK | Asserts, but against something OUTSIDE this repo. | No — a red result often says nothing about the code |
Anything that downloads is given a throwaway cache with --cache. Without
it a run would read back whatever a previous run left in the per-user cache —
so the suite would pass or fail depending on history — and would quietly fill
a developer’s profile with fixture packages. Get-UiScreenshot.ps1 takes the
same flag, for the same reason: a scene 6 with nothing to download is not
worth a screenshot.
Test-HttpClient.ps1 builds and runs a real pacenotes-server: WinHTTP
cannot be unit tested, so the only honest test is against the server the
installer actually talks to. It drives the binary’s --selftest-http and
--selftest-report flags, which write a report to a file rather than opening
a window — which is why it runs in CI while the GUI suite does not. Besides
the download matrix it covers the two endpoints that are not downloads:
GET /whoami, the only way to validate an access key (and the /packages
behaviour that is the reason — a bad key gets a 200 and the public listing,
never a 401), and POST /report/conflict, asserting on the JSONL line the
server stored rather than on the installer’s own report.
cargo test ignores ui/ and the
.ps1 files, so the two suites coexist in one directory without interfering.
How tests here are written
Two rules, both learned by writing worse versions first.
1. No arbitrary inputs
A test input must stand for a real situation the user can be in. The
game-folder field exists to tell “this is your Richard Burns Rally install”
from “this is some other folder”, so its fixtures are exactly that: a
directory containing RichardBurnsRally_SSE.exe, and one that is not. Handing
it $env:TEMP would assert nothing about the thing the field is for — and
indeed an earlier version of this suite did that, and happily passed while the
code accepted any directory that existed.
Likewise, the “Install proceeds once all three categories have a pick” path
was deliberately not asserted while those lists were empty: injecting
placeholder rows would only have proved that a listbox can hold a string. It
is asserted now, in ui/Test-WizardDownload.ps1, because scene 5 fills the
lists from a real catalogue served by a real server — and the rows it checks
for are counted from that catalogue rather than written out a second time.
2. Expected text comes from the resource, not from a copy
ui/Test-UiLayout.ps1 reads res/installer.rc via Get-ResourceStrings and
asserts each control shows exactly what the resource defines for the language
under test. It runs the wizard against a deliberately dead server
(--server http://127.0.0.1:1) so scene 2 takes its documented OFFLINE path
and the walk is deterministic; the online paths belong to
Test-HttpClient.ps1. Hardcoding the strings would only prove that two hand-written
copies agree, and would keep passing if a caption were bound to the wrong id or
a translation were never added.
The one deliberate exception is the language picker, which is shown before a
language has been chosen and therefore renders in the OS UI language. It cannot
honor a choice the user has not made, so its captions are asserted to be a
shipped string for their id in some language — which still catches the <id>
fallback that i18n::load_string emits for a missing or mis-bound id.
Traps these scripts encode
Do not “simplify” these away; each cost real debugging time and the workaround is commented at its call site.
-
GetWindowTextdoes not cross process boundaries. For a window owned by another process it returns only the cached window title. Static and Button captions live in that cache and read back fine, but Edit and ListBox contents come back empty — silently turning assertions into false passes.Get-CtrlTextusesWM_GETTEXT, which USER32 does marshal. -
TCM_SETCURSELdoes not notify the parent. Setting a tab selection that way skips the app’sTCN_SELCHANGEhandler, so the test proves nothing. A syntheticWM_NOTIFYis not an option either — it needs anNMHDRin the target process’s address space.Select-Tabsends arrow keys, so the tab control raises a genuineTCN_SELCHANGE. -
PowerShell
-liketreats[...]as a character class. Log lines are prefixed with[Game Name], so-like "*[BeamNG]*"matches almost any text. Use.Contains()for those. -
PowerShell variable names are case-insensitive. A local
$tabsilently clobbers a parameter$Tab. Helper parameters here are named distinctly. -
SetForegroundWindowis refused to a process that is not already in the foreground, so a screen grab silently captures whatever IS on top — this produced screenshots of unrelated applications.Get-UiScreenshot.ps1usesPrintWindowwithPW_RENDERFULLCONTENT, which asks the window to render itself and ignores z-order. -
Wizard page controls are GRANDchildren of the property sheet. The sheet owns one child dialog per page and only the active one is visible, so
GetDlgItemon the sheet finds none of them.Get-ActivePagefinds the visible page first; a test that skipped it would conclude every control was missing. -
A MessageBox’s OK button is not reliably control id 1. On this app’s own warning boxes it came back as id 2, so the posted
WM_COMMANDIDOK thatDismiss-Modalused to send closed nothing. A box left standing is particularly nasty here: the page underneath is disabled, yet its child controls still answer messages sent straight to them, so the symptom is a click that “does nothing” several steps later.Dismiss-Modalnow clicks whichever button the box actually has. -
Tree and list selections must be made by CLICK, not by key. Sending
VK_SPACEto a checkbox tree from outside the process toggled nothing, andVK_HOME/VK_DOWNmoved a listbox selection without the parent ever gettingLBN_SELCHANGE— so the app never learned what was picked. AWM_LBUTTONDOWNon the row is handled from the message alone and raises the real notification.Invoke-TreeCheckRootsandSelect-ListItemtake the geometry from the control (item height, indent) rather than assuming it.
Requirements
- The GUI tests are Windows-only and need an interactive desktop
session; they will not run on a headless agent.
cargo testand the size gate have no such restriction. - Build before running the GUI tests: they default to
target/x86_64-pc-windows-msvc/release/pacenotes-installer.exe. Pass-ExePathto point elsewhere. - Scripts kill the installer in a
finallyblock and delete their fixtures, so a failed run leaves no stray window or temp folder behind.
src/zip/fixtures
Binary inputs for the Rust tests. Two archives, used by src/zip/tests.rs.
overlay-deflate.zip / overlay-stored.zip
Both hold the same thing — a miniature game-root overlay, which is the only shape a real package ever has:
Plugins/Pacenote/ (directory entry)
Plugins/Pacenote/config/ (directory entry)
Plugins/Pacenote/sounds/ (directory entry)
Plugins/Pacenote/config/pacenote/Rbr.ini 24 bytes, CRLF
Plugins/Pacenote/sounds/polish/male/Kamil/001.ogg 75 bytes
Plugins/Pacenote/sounds/polish/ (directory entry)
Plugins/Pacenote/sounds/polish/male/ (directory entry)
aa/evil.txt 9 bytes
Three properties matter, and all three are why these are real archives rather than bytes this repository wrote:
- Not every directory is an entry.
Plugins/has no entry, and neither does the directory holding a nested file. An extractor that only created the directories it was told about would fail on the first real package. - One is deflated, one is stored.
-CompressionLevel NoCompressiongives method 0 throughout; the default gives method 8. Both occur in the wild and both must read identically. aa/evil.txtis exactly as long as../evil.txt. The zip-slip test renames it in place, in both the local header and the central directory, which turns a valid archive into a hostile one without moving a single offset — and without this project owning a zip writer.
Produced with PowerShell’s Compress-Archive, i.e. .NET’s own ZipArchive:
the library that wrote every package the legacy installer ever read, and the
one deploy/README.md tells an operator to use. Reading back an archive we
had also written would only prove our two halves agree with each other.
To regenerate (from installer/):
$src = "$env:TEMP\zipsrc"
New-Item -ItemType Directory -Force "$src\Plugins\Pacenote\config\pacenote",
"$src\Plugins\Pacenote\sounds\polish\male\Kamil", "$src\aa" | Out-Null
Set-Content -NoNewline "$src\Plugins\Pacenote\config\pacenote\Rbr.ini" "[PACENOTE]`r`nname=Kamil`r`n"
Set-Content -NoNewline "$src\Plugins\Pacenote\sounds\polish\male\Kamil\001.ogg" `
("OggS`0fake audio payload, repeated: " + ('x' * 40))
Set-Content -NoNewline "$src\aa\evil.txt" "harmless`n"
Compress-Archive "$src\Plugins","$src\aa" src\zip\fixtures\overlay-deflate.zip -Force
Compress-Archive "$src\Plugins","$src\aa" src\zip\fixtures\overlay-stored.zip `
-CompressionLevel NoCompression -Force
The byte counts in src/zip/tests.rs come from the files above, so if you
change the content, change the assertions with it.
What these fixtures cannot tell you
That the reader copes with the packages that actually exist. For that there
is a MANUAL CHECK in src/zip/tests.rs which walks a directory of real ones:
$env:PACENOTES_ZIP_CORPUS = "D:\Games\RichardBurnsRally\.pacenotes\cache"
cargo test --target x86_64-pc-windows-msvc --lib every_real_package -- --ignored --nocapture
Any directory of real package zips will do. A game folder the installer has downloaded into is the easy one, since that is where its cache lives.
Binary size report
The installer has a hard size gate (see
Installer requirements, “The size budget”).
This is the last cargo bloat measurement saved to docs/bloat.txt: where
the bytes of the release build go, by crate.
pacenotes-installer size report
generated 2026-10-26, release profile (opt-level=z, lto=fat, panic=abort, strip=symbols)
file size: 598528 bytes (584.5 kB) against a 600 kB gate
Read this with tests/Test-BinarySize.ps1. The .text total below is only part
of the file: the rest is resources, and res/installer.rc embeds ../../rbr.ico,
which is 85294 bytes on its own - the largest single item in the binary and
the first place to look for room.
File .text Size Crate Name
3.9% 6.0% 23.0KiB std std::sys::backtrace::__rust_begin_short_backtrace<pacenotes_installer::worker::spawn_reporting::closure_env$0<pacenotes_installer::wizard::start_install::closure_env$1>,tuple$<> >
2.0% 3.0% 11.8KiB pacenotes_installer pacenotes_installer::engine::execute
1.4% 2.1% 8.3KiB pacenotes_installer pacenotes_installer::zip::read_entry
1.1% 1.6% 6.2KiB pacenotes_installer pacenotes_installer::wizard::s6_proc
1.0% 1.6% 6.1KiB serde_core serde_core::de::impl$5::deserialize<alloc::vec::Vec<pacenotes_shared::PackageInfo,alloc::alloc::Global>,ref_mut$<serde_json::de::Deserializer<serde_json::read::SliceRead> > >
1.0% 1.5% 5.8KiB serde_core serde_core::de::impl$5::deserialize<alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_installer::tree::CachedPackage,alloc::alloc::Global>,ref_mut$<serde_json::de::Deserializer<serde_json::read::SliceRead> > >
0.9% 1.4% 5.3KiB pacenotes_installer pacenotes_installer::main
0.8% 1.2% 4.7KiB pacenotes_installer pacenotes_installer::SelfDownload::run
0.8% 1.2% 4.6KiB pacenotes_installer pacenotes_installer::wizard::load_tree
0.8% 1.2% 4.5KiB serde_core serde_core::de::impl$5::deserialize<alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_shared::GameSchema,alloc::alloc::Global>,ref_mut$<serde_json::de::Deserializer<serde_json::read::SliceRead> > >
0.7% 1.1% 4.3KiB std alloc::str::impl$5::to_lowercase
0.7% 1.1% 4.3KiB std std::sys::backtrace::impl$0::print::impl$0::fmt
0.7% 1.0% 4.0KiB pacenotes_installer pacenotes_installer::api::download_to_cache
0.6% 1.0% 3.8KiB pacenotes_installer pacenotes_installer::zip::central_directory
0.6% 0.9% 3.6KiB serde_core serde_core::de::impl$5::deserialize<alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_installer::ledger::LedgerEntry,alloc::alloc::Global>,ref_mut$<serde_json::de::Deserializer<serde_json::read::SliceRead> > >
0.6% 0.9% 3.3KiB std std::path::PathBuf::_push
0.5% 0.8% 3.2KiB pacenotes_installer pacenotes_installer::wizard::s5_proc
0.4% 0.7% 2.6KiB serde_core serde_core::de::impl$5::deserialize<alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_installer::ledger::LedgerFile,alloc::alloc::Global>,ref_mut$<serde_json::de::Deserializer<serde_json::read::SliceRead> > >
0.4% 0.7% 2.6KiB pacenotes_installer pacenotes_installer::wizard::s2_proc
0.4% 0.6% 2.5KiB pacenotes_installer pacenotes_installer::wizard::s4_proc
0.4% 0.6% 2.5KiB serde_core serde_core::de::impl$5::deserialize<alloc::vec::Vec<pacenotes_shared::SchemaCategory,alloc::alloc::Global>,ref_mut$<serde_json::de::Deserializer<serde_json::read::SliceRead> > >
0.4% 0.6% 2.5KiB pacenotes_installer pacenotes_installer::install::set_ini_key
0.4% 0.6% 2.4KiB pacenotes_installer pacenotes_installer::http::send
0.4% 0.6% 2.4KiB pacenotes_installer pacenotes_installer::api::Api::fetch_manifest
0.4% 0.6% 2.2KiB pacenotes_installer pacenotes_installer::wizard::redetect
0.4% 0.5% 2.1KiB pacenotes_installer pacenotes_installer::wizard::s7_proc
0.3% 0.5% 2.0KiB std alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_installer::ledger::LedgerEntry,alloc::alloc::Global>::insert<alloc::string::String,pacenotes_installer::ledger::LedgerEntry,alloc::alloc::Global>
0.3% 0.5% 2.0KiB std core::str::pattern::impl$31::is_contained_in
0.3% 0.5% 1.9KiB pacenotes_installer pacenotes_installer::install_panic_hook::closure$0
0.3% 0.5% 1.9KiB std std::env::args
0.3% 0.5% 1.8KiB std alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_installer::tree::CachedPackage,alloc::alloc::Global>::insert<alloc::string::String,pacenotes_installer::tree::CachedPackage,alloc::alloc::Global>
0.3% 0.5% 1.8KiB std core::slice::sort::stable::quicksort::quicksort<ref$<str$>,bool (*)(ref$<ref$<str$> >,ref$<ref$<str$> >)>
0.3% 0.5% 1.8KiB std core::slice::sort::stable::quicksort::quicksort<pacenotes_installer::tree::Row,alloc::slice::impl$0::sort_by::closure_env$0<pacenotes_installer::tree::Row,pacenotes_installer::tree::impl$4::build::closure_env$4> >
0.3% 0.5% 1.8KiB std alloc::collections::btree::map::BTreeMap<alloc::string::String,pacenotes_installer::ledger::LedgerFile,alloc::alloc::Global>::insert<alloc::string::String,pacenotes_installer::ledger::LedgerFile,alloc::alloc::Global>
0.3% 0.4% 1.7KiB std core::slice::sort::stable::quicksort::quicksort<tuple$<alloc::string::String,alloc::string::String>,alloc::slice::impl$0::sort_by::closure_env$0<tuple$<alloc::string::String,alloc::string::String>,alloc::collections::btree::map::impl$88::from_iter::closure_env$0<alloc::string::String,alloc::string::String,core::iter::adapters::filter_map::FilterMap<core::slice::iter::Iter<pacenotes_installer::choices::ChoiceList>,pacenotes_installer::choices::impl$1::picks_by_category::closure_env$0> > > >
0.3% 0.4% 1.7KiB std core::slice::sort::stable::quicksort::quicksort<tuple$<alloc::string::String,pacenotes_installer::tree::CachedPackage>,alloc::slice::impl$0::sort_by::closure_env$0<tuple$<alloc::string::String,pacenotes_installer::tree::CachedPackage>,alloc::collections::btree::map::impl$88::from_iter::closure_env$0<alloc::string::String,pacenotes_installer::tree::CachedPackage,core::iter::adapters::map::Map<alloc::vec::into_iter::IntoIter<pacenotes_installer::tree::CachedPackage,alloc::alloc::Global>,pacenotes_installer::worker::apply::closure_env$0> > > >
0.3% 0.4% 1.6KiB std std::sys::backtrace::output_filename
0.3% 0.4% 1.6KiB pacenotes_installer pacenotes_installer::wizard::s4b_proc
0.3% 0.4% 1.6KiB pacenotes_installer pacenotes_installer::api::Api::report_conflicts
0.3% 0.4% 1.6KiB std std::sys::path::windows::get_long_path
35.5% 53.6% 207.5KiB And 1087 smaller methods. Use -n N to show more.
66.2% 100.0% 387.0KiB .text section size, the file size is 584.5KiB
File .text Size Crate
34.6% 52.2% 202.1KiB std
17.2% 26.0% 100.6KiB pacenotes_installer
5.8% 8.7% 33.7KiB serde_core
2.4% 3.7% 14.2KiB serde_json
0.5% 0.7% 2.7KiB [Unknown]
0.5% 0.7% 2.7KiB miniz_oxide
0.4% 0.6% 2.3KiB memchr
0.2% 0.3% 1.3KiB pacenotes_shared
0.1% 0.1% 565B zmij
0.1% 0.1% 347B enum2$<core
0.1% 0.1% 340B itoa
0.0% 0.1% 247B
0.0% 0.0% 82B enum2$<serde_json
0.0% 0.0% 30B `__scrt_common_main_seh'
0.0% 0.0% 8B vswhom_sys
66.2% 100.0% 387.0KiB .text section size, the file size is 584.5KiB
Note: numbers above are a result of guesswork. They are not 100% correct and never will be.
API reference
Generated by rustdoc from the code on every build of this site, private items included: it is for working on the project, not a library to depend on. The comments it shows are the ones in the code, so this is the reference that cannot drift from it.
| Crate | What it holds |
|---|---|
pacenotes_shared | The protocol types both sides compile against: serde only. |
pacenotes_server | The package and auth server: routes, the access decision, the store and manifest gen, conflict reports, the credential CLI. |
| Installer — library | The installer’s Win32-free logic: the tree and choice models, cache, ledger, engine, restore points, zip, sha256, the run log. |
| Installer — application | The Windows binary: the wizard and its scenes, the worker thread, WinHTTP, the UI vocabulary, strings, elevation. |
mdbook_repo_links | This book’s preprocessor, which points links to repository files at GitHub. |
The installer’s two halves share a crate name, so each has a reference of
its own. Their default server address is always the example one from
installer/.env.example, never a deployment’s.
To build these pages locally, run docs/build.sh; see
Development guide.
Project task list (ordered milestones)
Work top to bottom. Each milestone leaves the repo buildable. Per-crate
detail is in installer/TASKS.md and server/TASKS.md; what a task must
satisfy before it counts as done is in requirements.md.
M0 — Repo bootstrap
- Lay out the workspace:
shared/,installer/,server/ - Write the CLAUDE.md files (root, installer, server)
- Copy the legacy C# sources into
legacy/for reference -
git initand a first commit - Pin every dependency to an exact version and commit
Cargo.lock - Port the legacy GitHub Actions workflows (
ci.yml, plus the threecd-*deploy workflows)- Give the installer its own CI job: it is excluded from the workspace, so the server job’s fmt and clippy never reached it
- Run fmt, clippy, tests, version sync and the size gate there
- Leave the GUI tests out of CI — they need an interactive desktop
M1 — Shared protocol crate
- Make
PackageInfo,ManifestandApiErrorcompile - Unit-test the JSON round trip and
Manifest::visible_tofiltering - Get
cargo test -p pacenotes-sharedgreen
M1a — Protocol v2: install metadata
See root CLAUDE.md, “Install model”.
- Extend
PackageInfowithcategory,display_name,exclusive_groupandrequires- Add
gameas well, so one server can serve four games without every package appearing under every one
- Add
- Unit-test that old manifests still deserialize, and that absent optionals are not serialized
- Add
shared::PackageRef,PackageInfo::label(),required_refs()andManifest::get()
M2 — Server MVP (real files, no auth yet)
- Work through
server/TASKS.mdS1..S4 - Serve
GET /health,GET /packagesandGET /download/... - Check by hand with curl: health, anonymous listing, a byte-identical download, a 404 for an unknown package, and a rejected traversal
M3 — Server auth
- Work through
server/TASKS.mdS5..S7 - Add the libSQL schema and migrations, token hashing and the directory-grant check
- Add the admin CLI: mint and revoke credentials
- Add the admin CLI: grant and ungrant directories
- Integration-test the listing and download matrix across
{anon, no grants, one directory, several}
- Cover expired, revoked, ungranted, 404 and traversal while the harness is there
M4 — Installer skeleton (Windows)
- Work through
installer/TASKS.mdI1 (toolchain, resources, size gate) - Work through I2 and I2a (the UI per
installer/DESIGN.md) - Work through I3 (the WinHTTP client and
--selftest-http) - Open a Win32 dialog and pass the release size gate
- Fetch
GET /packagesthrough WinHTTP against a real server- Cover bearer auth and the 401, 404 and unreachable surfaces
- Keep it desktop-free so CI can run it
M5 — Installer cache and install engine
- Work through I4 (worker thread, progress and cancel)
- Work through I5 (api, std-only sha256, sha-keyed cache, verified downloads)
- Build the download scene from where packages sit on the server, and show each leaf’s state
- Build the configuration, progress and summary scenes
- Work through I6 (zip reader)
- Work through I7: the ledger, overlay extraction, one-time backup of files the ledger does not own, and uninstall from the ledger
- Make Apply a diff of selection against ledger, honouring exclusive
groups and enforcing
requires - Give each game its own configuration step, with no shared
abstraction over them
- RBR: edit the existing Pacenote config, preserving the rest
- Split downloading and installing into two screens with the
configuration step between them, and offer both routes on scene 3
- Not in the original plan: a download fills a cache, an install writes to the game folder, and a user should be able to do either without the other
M6 — Installer auth and polish
- Work through I8: use the access key as the bearer for every request
- Validate it with
GET /whoami, the only endpoint that can - Warn when a key is close to expiring, naming the date
- Validate it with
- Build the restore scene: go back to any earlier state, or remove everything, after seeing exactly what that would do
- Work through
installer/TASKS.mdI9 (polish) and I10 (release gate) - Audit the final size against the 700 kB gate
- Save a
cargo bloatreport todocs/bloat.txt
- Save a
M7 — Ship
- Write the deploy set: Containerfile, Podman Quadlet, Caddy site
block and
deploy/README.md - Build and push the image from CI, tagged with the git sha, so the
Quadlet pins a tag and a restart never rebuilds
- Build both the server and the installer in CI
- Store the build artifacts
- Installer: GitHub Release asset
- Server: private GHCR image only, no workflow artifact
- Source the executable from CI when the Quadlet deploys
-
deploy/pacenotes-update, the deploy key’s forced command - Roll back to
:previouswhen the new container is unhealthy - Fix
podman untagstripping:current(found by the test)
-
- Make publishing content mean running
manifest genalone, and say so indeploy/README.md - First tagged release through the whole pipeline: secrets set, GHCR package private, deploy job green
- Author the sidecar
.tomlfiles for the real packages: display names,exclusive_groupon the config variants,requireson the voice packs - Write reproducible release-build instructions for both binaries
- Smoke-test on a fresh Windows VM: download, install a subset, switch a config variant, uninstall, restore
- Rewrite
README.mdto match the application as it is - Publish every document in the repository as one mdBook on GitHub
Pages from CI, with the rustdoc API reference and the architecture
diagrams redrawn in D2 and rendered to SVG; no Python anywhere
- Copy
tools/into the server image build: the new workspace member broke it at v/2.0.1, and CI now loads the workspace from what the Containerfile copies
- Copy
Supply chain and operations
From the design review’s period 2. The findings themselves stay in
DESIGN-REVIEW.md.
- Sign the
.exewith Authenticode in CD and publish its SHA-256 (O2) - Pin the toolchain and the base images by digest (O2)
- Publish a versioned server artifact rather than a branch tip (O2)
- Bind Caddy’s plain-HTTP block to loopback, or remove it (O2)
- Make the
EnvironmentFilepath agree withdeploy/README.md(O2) - Confirm the default hostname stays under the project’s control, and never ship a provider hostname (C3)
- Back up
auth.dband the packages themselves (S9) - Document publishing by copy-to-
.tmp-then-rename (S4) - Document that renaming or moving a package folder is a breaking change, because the folder path is the package’s identity (P13)
Installer tasks (I1..I10)
Specs are in installer/requirements.md, rules in installer/CLAUDE.md,
behaviour in installer/DESIGN.md. Test conventions are in
tests/README.md — read it before adding a test.
I1 — Toolchain and window
- Add the
x86_64-pc-windows-msvctarget - Compile
res/installer.rcfrombuild.rsviaembed_resource- Watch
../rbr.icotoo, or a new icon is not a reason to rebuild
- Watch
- Put the app manifest, icon and VERSIONINFO in the .rc
- Use a plain
fn mainrather thanwWinMain - Pin every dependency with
= - Add
tests/Test-BinarySize.ps1as the release gate
I2 / I2a — Pre-wizard layouts
Superseded by I2b. Two layouts were built and removed; what survived them is in the current design. See the CHANGELOG and git history.
I2b — Wizard
Protocol groundwork in shared
- Derive the tree from the package’s storage path:
category(),tree_levels(),leaf() - Add
valid_nameas the choke point that stops a lookup key becoming a path, andPackageRef::parsesplitting on the first separator - Add the remaining
GAME_*constants - Add the category vocabulary
Shell
- Host the scenes in a PropertySheet, one dialog template each, with a per-scene primary label and a Cancel that confirms
- Own
WizardStateat the sheet - Remove the superseded ribbon and two-column code and strings
- Size every page 330x215 DLU, which scene 5 forces: three lists visible at once, each full width so a co-driver’s path is not clipped
Scenes
- Scene 1 — language combo, defaulting to the OS UI language
- Scene 2 — MOTD with an offline fallback, the secret field, and
server status from
/health - Scene 3 — path edit, Browse, and a game dropdown fed by a debounced scan
- Scene 3 — three route buttons: Download/Update, Configure/Install,
Restore/Uninstall
- Lay them out in two columns, the main flow alone on the right as the default button, wider and as tall as the other two together
- Measure that arrangement in
Test-UiLayout.ps1, since “looks primary” is the whole point of it - Leave the sheet’s Next disabled: the routes are alternatives, not one primary and two afterthoughts
- Scene 4 — the catalogue as a checkbox tree with partial states and
parent/child propagation, falling back to the cache when offline
- Refuse Download until every choice category has a pick, naming the ones missing
- Scene 4b — the download phase on a page of its own, between the tree
and the questions
- Share scene 6’s template and handlers: it is the same screen at an earlier moment
- End a failure or a cancel at the summary rather than walking on to questions about an install that cannot happen
- Scene 5 — one single-select list per choice category, each option
labelled with its path inside that category
- Preselect a sole option, and refuse Install until every list that has options has a pick
- Default to what is already installed, so the Configure route does not make the user re-answer last time’s questions
- Carry picks across a trip Back to scene 4 and forward again
- Scene 6 — phase label, progress bar, current package with its own
byte count, and a live log pane
- No Back and no Next: the page runs itself and walks on
- Scene 7 — result, game and folder, the picks, the extras, what came
down the wire, and where the log is
- Hide the log pane on success and show it on failure
- The restore scene — see “Restore” below
- Add the run log (
runlog.rs): in memory plus a line-by-line flush- Write it to
<game dir>\.pacenotes\logs\install-<stamp>.log, one file per run, newest five kept - Attach it when scene 3 settles a folder, replaying what came before, so the file is the whole run
- Write it to
- Write English and Polish strings for every caption, label and modal
Models (no Win32, tested cross-platform)
-
tree.rs— manifest plus cache to nodes with states, check propagation and per-category validation -
choices.rs— scene 5’s lists, the path labels, preselection and the no-choice extras
I3 — WinHTTP client
- Wrap open/connect/request; add
getandget_streamingwith a sink - Read in 64 KiB chunks, reporting progress
- Set explicit timeouts, or a dead server parks the worker and Cancel does nothing
- Treat a body shorter than its Content-Length as an error
- Turn WinHTTP error codes into readable text via winhttp.dll
- Add
--selftest-http, writing a report to a file or a message box- Never write the token into the report
I4 — Worker thread and progress protocol
- Post a
Messageper job as a leakedBoxthe receiver reclaims - Spawn with
std::thread; only the UI thread touches controls - Carry a request id, so a late answer to a superseded request is recognised and dropped
- Support cancellation through a shared flag read between chunks
- Veto the window close until the worker reports back
- Carry phase, item, per-item and per-phase byte counts, and an
optional log line in the progress message
- Throttle posts to ten a second
- Hand the job a
Reporterrather than the window handle, so “a worker never touches a control” holds by construction
I5 — API and cache
- Write
sha256.rswith no dependencies- Test against the published vectors AND against chunked input
- Add
fetch_manifest(game),fetch_motdandhealth - Lay the cache out at
<game dir>\.pacenotes\cache\- Open it when scene 3 settles a folder, as the log does
- Key
cache.jsonby game as well, since directory+name is not unique across games - Store a
PackageInfosnapshot per entry, so the tree can show local-only packages offline - Write the index atomically
- Download to a temp file, verify the sha256, then move into place
- Build the URL with
PackageInfo::download_path(), never by hand - Reject a digest mismatch
- Build the URL with
- Give each leaf a cache state, withholding an entry whose payload has vanished rather than failing at install time
I6 — Zip reader
- Read the EOCD and central directory; inflate and stored
- Create the directories a .NET zip carries no entries for
- List entries without extracting, for the backup phase
- Return
/-separated paths on every host: they are ledger keys - Guard against zip-slip, treating
\as a separator and rejecting:outright- Fail the package WHOLE, so nothing is half-installed
- Check each entry’s CRC32 during extraction
- Use a nibble table rather than the usual 256-entry one
- Detect and refuse Zip64 by name rather than misreading it
- Decode CP437 names without the UTF-8 flag, as .NET does
- Test against real
Compress-Archiveoutput, deflated and stored - Add a manual check over a directory of real packages
I7 — Ledger and engine
Ledger (ledger.rs)
- Read and write
.pacenotes\installed.jsonatomically - Key entries by
game/directory/name - Record per file: the path as written, a case-folded map key, the
sha256 of what was written, and
prior - Record
exclusive_groupas installed, so uninstall works offline - Add
owners_of(path), so a file is deleted only when no other entry lists it - Write
state: "installing"BEFORE extracting - Recover in
plan()rather than a startup scan: aninstallingentry is torn down and laid again - Never overwrite an unreadable ledger — it may be a newer build’s
Engine (engine.rs)
-
plan(): selection plus ledger plus cache to ordered actions- Supersede a ledgered member of the same category or group
- Reinstall when the sha changed
- Enforce
requireswithin the game, satisfied by the SELECTION alone — “already installed” does not count, since anything unselected is uninstalled in the same run
- Uninstall before install, always
- Pre-flight before touching anything: free space and a writability probe
- Verify each payload by its sha256 rather than CRC-checking every entry
- Write each file to a temp name and rename
- Uninstall by
prior, and only when the on-disk sha still matches what was written - Prune emptied directories, deepest first
- Detect overlaps as a pure function over the file lists the pre-flight already read
- Measure “the game is not running” — the engine refuses when told,
but
wizard/scene/installation.rsstill passesfalse- Check for the marker process (I9)
Conflict reporting
- Log every overlap, naming each writer in install order
- POST the report after
execute(), so what is reported is what happened- Trim it to the server’s 8 KiB cap by measuring, not guessing
- Convert keys to the refs the report carries, dropping any that will not split rather than guessing
- Send no token, no identity and no local paths, and say in the log that it was sent
- Add
post_jsontohttp.rs, sharing one request path withget
Per-game configuration step (install.rs)
-
configure(game, game_dir, picks)dispatching on the game, handed only a category-to-key map - RBR: set
languageandsoundsinPaceNote.ini, preserving every other line, and write from a template only when the file is absent- Report a missing plugin directory for the caller to localize
- Keep the author’s spacing, inline comments, commented-out lines and the file’s CRLF
- Check
soundsagainst what is on disk, keeping what the package shipped when it is not there - Write nothing on a second run, so a re-run takes no restore point
- Put the step’s edits back into the ledger
(
engine::record_configured), or the next uninstall reads our own edit as somebody else’s - Log a file the step wrote that no package owns, rather than adopting it — see requirements.md, “deliberately not done”
Per-game choice categories
- Take the list from the compiled-in schema rather than a constant
- Keep the localized label and explanation in the
.rctables, mapped by category id, because UI text must not come from a server
- Keep the localized label and explanation in the
- Demand a pick on scene 4 only in those categories
- Ask one question per entry on scene 5, in schema order, and skip the scene when a game declares none
- Carry
install_rootas a token the installer knows, never a path- Refuse a token this build cannot place
- Decide what the token means for the three games other than RBR
Restore
- Write a restore point whenever a run changes anything, holding the
pre-run ledger and the files that run replaced
- Name points by UTC stamp, sortable and legal on Windows
- Suffix the name when two runs share a second, rather than overwriting the first run’s way back
- Never prune points automatically
- List points newest first, each a date plus what the run after it did
- Preview the chosen point: removals first, then put-backs, then what is left alone, then what cannot come back
- Offer “remove everything this installer added” as a restore to an empty state — the same code, not a second path
- Plan purely (
restore::plan), and hand the worker the same value the preview showed- Install by payload digest, not through the cache index, which holds only the newest version of each key
- Leave a package whose payload is gone installed rather than removing what cannot be replaced
- Write the snapshot’s per-file provenance afterwards, or a later uninstall deletes a file the user always had
- Run it on the progress screen like any other phase
- Take a restore point of its own first
- Re-run the configuration step with the picks the restored state implies
- Re-download a payload the restore needs but the cache lacks — see requirements.md, “deliberately not done”
Crash reporting
- Install a panic hook that writes file, line and message to the run
log, or beside the exe before a folder is settled
- Open a window for the FIRST panic only: the modal pumps the queue and can deliver the message that panicked
- Show the user something localized and plain, offering to open the
log
- Re-apply the chosen language first, since the hook can run on the worker thread
- Add
--selftest-panicandtests/Test-CrashReport.ps1
Tests
-
plan()table tests: fresh install, no-op re-run, switching a pick, supersede, changed sha, withdrawn package, unfinished install, everyrequirescase -
execute()in a tempdir: unowned-file backup, an identical file put back on uninstall, a file replaced by Steam left alone, a shared file kept, recovery frominstalling, a corrupt payload, free space, restore points - The RBR step against the real shipped
PaceNote.ini - End to end through the GUI against a live server
- Restore: the bad-update rollback, the provenance proof, a missing payload, the point-name collision, and a full pass through the UI
I8 — Auth flow
- Use the secret as the bearer for
/packagesand/download- Send no token with
/report/conflict
- Send no token with
- Validate it with
GET /whoami, not/packages- Map 401 to rejected and everything else to “could not ask”, so a protocol change cannot lock a paying user out
- Warn when a key is within 14 days of expiry, naming the date rather than a day count
- Handle no 403 at all, and pin that in the auth matrix
Server protocol changes
- Check the install schema by hash (S11)
- Compile
schemas/<game>.tomlinto a static table frombuild.rs - Keep a list of older hashes a build still accepts, for rollouts
- Block downloading for that game only, and only when something has to come down the wire
- Run the check only on a manifest that came from a server
- Take behaviour from the installer’s own copy of the schema
- Test that the compiled-in copy is byte-identical and hashes the same, and that it has no CRLF
- Compile
- Accept versions of the form
sha-xxxxxxxx(D5) — nothing to do, as no scene shows a version - Give the test fixtures their own
schema.toml
Pre-release hardening
From the design review’s period 2. Specs for each are in
installer/requirements.md; the findings are in DESIGN-REVIEW.md.
Ordered security first, then what support needs, then what a user hits.
- C1 Decide what happens on a redirect
- Find out whether WinHTTP re-sends
Authorizationacross a host change, and record the answer - Turn redirects off and follow them by hand: a hop limit,
httpsonly, and the header dropped when the host changes - Refuse to redirect a request that carries a body, rather than choosing silently between re-sending it and making it a GET
- Pin it with
tests/Test-Redirects.ps1, driving the real WinHTTP path rather than a reimplementation of it
- Find out whether WinHTTP re-sends
- S1 Build the user agent from
CARGO_PKG_VERSION - §7 Give the run log a header and per-line timestamps
- D12 Try
/packageseven when/healthfailed - D12 Cap what
http::getwill buffer - P11 Probe writability on scene 3, offering to restart elevated
- §7 Retry a transient download, and list failures on scene 7
I9 — Polish
- Check free disk space (in
preflight, which knows what the plan needs) - Replace the unwraps left in UI paths with a modal and a log line
- Sweep orphaned payloads from the cache on startup
- Decide where “remove from cache” lives now there is no library view
- Fix keyboard and tab order, default buttons, and Esc per scene
- Download/Update → Back → Configure/Install → Back → Download/Update skips the
“at least one item checked” check, and repeating it locks scene 4; the
configuration screen must not open unless every category has at least
one item downloaded and cached
- Scene 4b fetched again what an earlier transfer had cached, once a selection could come back to it
- Show the message of the day with the line breaks of its
.txt - Give the buttons that carry the normal flow forward a light green background: Next, its relabelled forms, Finish, and Download/Update
- The installation process is quite slow. Check for possible optimisations, like copying, moving, writing files in bulk.
- Show a category with only some items ticked as partially selected, not blank
- Open the Download tree with every row expanded, and enlarge the window beyond 800x600 to show more of it
I10 — Release gate
- Save a
cargo bloatreport todocs/bloat.txt - Pass the 700 kB gate on the release build, and record the final size
- Version every build from its release tag,
v/X.Y.Z, so no version is ever typed by hand-
build.rsreads the tag, refuses any other shape, and falls back to0.0.0-devwithout one - The user agent and the conflict report take that version
- VERSIONINFO comes from a header
build.rswrites, so its four fields cannot drift -
Cargo.toml’s version stops meaning anything, and says so - Release on pushing a
v/*tag, retiring the merge and manual releases that readCargo.toml - Replace
Test-VersionSync.ps1with a test that builds from a tag and reads the version back out of the binary - Update DEVELOPMENT.md, the CLAUDE.md files and the test docs
-
- If over budget, pull a lever from
requirements.md: the icon first, then a hand-rolled JSON parser - Decide what to do about
vcruntime140.dll: document the dependency or build with a static CRT and re-measure - Manual end-to-end on a clean Windows VM, per game: online with and
without a secret, offline from cache, switching a pick on a re-run,
and a server dropped mid-download
- The clean VM is also what settles the vcruntime question
Server tasks (S1..S12)
Specs are in server/requirements.md, rules in server/CLAUDE.md, the
protocol in the root CLAUDE.md.
S1 — Compile and pin
- Build
pacenotes-serverand commit the lockfile - Wire tracing-subscriber with an env filter, defaulting to info
S2 — Config and skeleton
- Add a clap CLI:
servewith--listen,--packages,--db-urland env fallbacks - Add the axum router,
GET /health, and graceful shutdown on ctrl-c
S3 — Manifest store
- Load
manifest.jsonintoshared::Manifestbehind an RwLock, with a lazy mtime-based reload and directory-name validation - Serve
GET /packageswith public entries only - Add
manifest gen: scan the access directories for zips, record sha256, size andupdated_at - Merge sidecars in
manifest gen - Warn on dangling
requires, a sidecar without a zip, and a public package requiring a protected one - Unit-test the merge and the validation cases
S4 — Downloads
- Serve
GET /download/{directory}/{name}as a stream, with Content-Length and the zip content type - Assert that directory and name are lookup keys and never paths
S5 — libSQL auth store
- Apply
migrations/0001_init.sqlidempotently at startup - Parse the bearer header, hash it, and load the token row with its grants in one query
- Extract
TokenRow::is_liveso the listing filter anddecidecannot drift apart
S6 — Auth wiring
- Filter
/packagesto public plus granted directories, never 403 - Enforce
decideon/download - Never log tokens, and verify it against a live run
S7 — Credential admin CLI
-
token mintwith label, grants and expiry, printing the key once -
token grantandtoken ungrant, rejectingpublic -
token revokeandtoken list - Honour
expires_atandrevokedindecide
S8 — Integration and deploy
- Integration-test the {anon, no grants, dirA, dirA+dirB} × {public, dirA, dirB} matrix, plus expired, revoked, ungrant, 404 and traversal
- Write the deploy set: Containerfile, entrypoint, Podman Quadlet,
Caddy site block and
deploy/README.md - Deploy from CI: image built on a
v/*tag and pushed to private GHCR,deploy/pacenotes-updaterolls the Quadlet onto it - Author the sidecar
.tomlfiles for the real packages- Share
exclusive_group = "pacenote-config"across the config variants, and make each voice pack require its language pack - Set no categories: they come from the folder layout, and a
categorykey in a sidecar is a hard error
- Share
- Add a README snippet for the publish and access flows
S9 — Protocol rev. 2
- Make
manifest genrecurse, sonameis the zip’s path under its access directory at any depth - Skip names that
valid_namerejects, with a warning - Skip non-UTF-8 file names with a warning
- Make
/download/{directory}/{*name}a wildcard route - Serve
GET /motd?lang=xxwith anenfallback and no auth- Restrict
langto[a-z]{2,8}before it reaches the filesystem
- Restrict
- Skip
motd/inmanifest gen - Warn when a top-level folder is a known category, which means the access-directory level is missing
S9b — One server, every game
- Replace the packages root with a data root, laid out
<data>/<game>/packages/<directory>/<name>.zip - Walk games in
manifest genand takegamefrom the folder name- Reject
gameas a sidecar key
- Reject
- Filter
GET /packages?game=<id>, treating an unknown game as empty - Lead the download route with the game
- Add
valid_id,KNOWN_GAMES,Manifest::for_gameandPackageInfo::download_pathtoshared - Warn on every mis-filing case
- Integration-test two games holding the same directory and name
- Re-file the real
server/datatree to the layout
S10 — Conflict reports from installers
- Accept
POST /report/conflictwithout auth, bodyshared::ConflictReport, packages in install order - Treat every field as hostile: cap the body at 8 KiB, bound the counts, validate the ids and refs, drop invalid entries, echo nothing back
- Rate-limit per client and globally
- Take the client from
X-Forwarded-Foronly when the peer is a configured--trusted-proxy
- Take the client from
- Store append-only as JSONL under
data/reports/, stamped with the server’s own time- Reserve
reportsas a data-root name
- Reserve
- Warn in
manifest genabout pairs reported in the last 30 days - Keep every report free of user identity, and document that in
deploy/README.md
S11 — Per-game install schema
- Read
data/<game>/schema.toml: the categories, which ask for one pick, and a symbolicinstall_root- Keep the canonical copy at
schemas/<game>.toml, byte-exact
- Keep the canonical copy at
- Embed each game’s schema and the sha256 of its raw bytes in the manifest
- Publish strictly: no schema means the game publishes nothing, an unparsable schema skips the game, an undeclared category skips the package
- Document the rollout order in
deploy/README.md
S12 — Period-2 hardening
IDs point into DESIGN-REVIEW.md.
- Stop enumeration through status codes: 401 without a live key, 404 with one, no 403 at all (D3)
- Make token rows decode fail-closed (D8)
- Go through
get_value: libsql’s typed getter panics on a type mismatch rather than returning an error
- Go through
- Keep public downloads off the DB, fall back to the public listing on a DB error, and reject malformed tokens before any query (D9)
- Add
GET /whoami(D2) - Handle SIGTERM with a 20-second bounded drain (D10)
- Set
StopTimeout=30in the Quadlet, since podman’s 10 s would SIGKILL mid-drain
- Set
- Write
manifest.jsonatomically, skip only the broken package or game, and exit non-zero (S5) - Turn on WAL and
busy_timeoutfor the local DB (S7) - Return 503 when a file’s size differs from the manifest (S4)
- Log one INFO line per response, with a token-hash prefix (D11)
- Name the leaf in
Content-Disposition, RFC 6266 (D12) - Set cache headers: no-store and
Vary: Authorizationon/packages,ETagand 304 on public downloads (C2) - Make the manifest a pure function of zips, sidecars and schemas (D5)
- Derive a missing version as
sha-<8 hex> - Refuse to drop metadata unless told to, and add
manifest migrate-to-sidecars
- Derive a missing version as
- Write the log without colour codes when stdout is not a terminal
Requirements — how work is done here
Specs and standing rules for carrying out TASKS.md. Per-component specs
live beside their component: installer/requirements.md,
server/requirements.md.
What belongs where, so nothing lands in two places:
| File | Holds |
|---|---|
TASKS.md | WHAT to do, as a checklist. No prose. |
requirements.md | WHAT a task must satisfy before it counts as done. |
CLAUDE.md | Rules that constrain future code, and why they exist. |
installer/DESIGN.md | How the product behaves, screen by screen. |
CHANGELOG.md | What changed, when, and what it cost. |
.claude/session.md | What is being worked on right now. |
Global guidelines
- Follow the task lists top to bottom:
TASKS.mdfor milestones, theninstaller/TASKS.mdandserver/TASKS.mdfor the detail. - Tick items as they are finished. Work discovered while doing a task becomes a SUB-POINT of that task, not a new top-level one.
- Prefer
stdover a dependency everywhere, and especially ininstaller/— see the size gate below. - A protocol change means
shared/first, then both sides, in one commit. - Never commit on the user’s behalf. Hand work over for them to review.
Verification & definition of done
Two bars. Which one applies depends on the state the task leaves the application in — not on how large the change was.
Fast loop — for a task that leaves the build in pieces
For work that knowingly breaks something and needs the FOLLOWING task to make the application whole again:
cd installer; cargo fmt; cargo clippy --all-targets; cargo test
All three must pass. clippy runs with -D warnings in CI, so treat a
warning as a failure here too.
Full suite — for a task that leaves the application working
pwsh -File installer/tests/Run-AllTests.ps1
Which is: fmt, clippy, the cargo test binaries, the release version, the 700 kB size gate, the WinHTTP client against a live server, the crash report, the UI layout in both languages, and the wizard flow end to end. It needs an interactive desktop for the last three.
Then stop and ask the user whether they are happy with the state of the application. Make no fixes without explicit permission — a failing check is a finding to report, not an invitation to start changing things.
Always
- The installer binary stays under 700 kB, and imports nothing Windows
does not ship.
Test-BinarySize.ps1is part of the full suite; run it by hand after anything that adds code or resources. - A change to behaviour updates
installer/DESIGN.md; a change to a rule updates the matchingCLAUDE.md; both get aCHANGELOG.mdentry.
Decisions
When a task reaches a fork with no obviously right answer — a trade-off between safety and convenience, two defensible designs, anything that changes what a user sees — STOP, lay the options out with their costs, and wait for an answer. Do not pick one and carry on.
Installer requirements
What installer work has to satisfy. The RULES that constrain the code are
in installer/CLAUDE.md, the BEHAVIOUR screen by screen is in
installer/DESIGN.md, and how any task is verified is in the root
requirements.md.
Every task here also ends with: it builds on the Windows MSVC target,
tests/Test-BinarySize.ps1 passes, and cargo test is green for the
logic modules on any host.
The size budget
700 kB. It was 500 kB until the install engine landed, 600 kB until
the installer stopped needing the Visual C++ redistributable, and 620 kB
until the build reached exactly that and the maintainer raised it
(installer/CLAUDE.md, “The size budget”). The rules it produced did not move: no tokio, no
reqwest, no rustls, no GUI framework, and std over a dependency.
Measured on the release build for x86_64-pc-windows-msvc:
| After | Bytes | |
|---|---|---|
| nothing (baseline) | 107,008 | 104.5 kB |
| I1 — icon + VERSIONINFO | 193,024 | 188.5 kB |
| I3 — WinHTTP client | 237,056 | 231.5 kB |
| I2b part 1 — wizard + worker | 275,456 | 269 kB |
| I2b part 2 — tree model + scene 4 | 337,408 | 329.5 kB |
| I5 — sha256 + cache + verified download | 402,432 | 393 kB |
| I2b — scene 5 + the bigger page | 412,160 | 402.5 kB |
| I2b — scenes 6 and 7 + the run log | 427,520 | 417.5 kB |
| I7b — the ledger reachable from main | 452,608 | 442 kB |
| I7f — the engine wired in (gate raised to 600 kB) | 553,984 | 541 kB |
| restore scene + the recut icon | 590,336 | 576.5 kB |
| the hybrid CRT, no VC++ redistributable (gate raised to 620 kB) | 632,320 | 617.5 kB |
| scene 5’s readiness check (gate raised to 700 kB) | 634,880 | 620 kB |
Two levers are held in reserve, in this order:
- The icon. It was a single 256×256 frame costing 85 kB — more than
serde and the whole engine together. It is now 16/32/48 as BMP plus 128
as PNG, about 45 kB, built by
res/Build-Icon.ps1fromres/rbr-source.png. Dropping the 128 frame would buy ~31 kB more. - serde_json, about 48 kB with serde_core. The installer reads three
documents — the manifest,
cache.json,installed.json— and a hand-rolled parser over those is the planned fallback.
docs/bloat.txt holds the last measurement. build.rs watches
../rbr.ico, so changing the icon does rebuild.
Specs for the open work
C1 — redirects
ANSWERED, 2026-10-30, measured: WinHTTP re-sends Authorization to the
new origin. Two local listeners on two ports; the first answered 302 to
the second. Port B received Bearer <token> in full, and the client
reported RESULT: OK with B’s body — it never saw the 3xx and had no idea
the token had gone somewhere else.
So the leak is real, not theoretical. A redirect today sends the user’s access key to whatever host a 302 names: a misconfigured CDN, a hijacked DNS record, or an operator who moved downloads without thinking about the header. Nothing in the installer would notice or log it.
The fix, which was always going to be the shape regardless of the answer:
- redirects disabled (
WINHTTP_OPTION_REDIRECT_POLICY), followed by hand; - a small hop limit;
httpsonly — never a downgrade;Authorizationdropped the moment the host changes.
A bearer token must not reach a host the operator merely pointed at.
tests/Test-Redirects.ps1 pins three of the four: a cross-origin redirect
is followed WITHOUT the token, a same-origin one keeps it, and a loop ends
by itself. The https→http refusal is NOT covered — an HttpListener on
loopback cannot easily serve real TLS, and http.rs lives in the bin, out
of reach of tests/. It is a two-line check (target.secure && !next.secure) sitting directly above the hop that would otherwise happen,
and it is the one piece of this policy resting on review rather than on a
test. A test for it needs either a loopback TLS certificate or http.rs
moved into the lib.
I10 — the version comes from the tag
Decided 2026-10-31: a release is a pushed tag shaped v/X.Y.Z, and
that tag is the only place a version is written. Nothing is bumped by hand.
build.rsowns the rule, so it lives in one place: it readsPACENOTES_RELEASE_TAG(CI sets it to the pushed tag), accepts exactlyv/plus three decimal numbers, each 0..=65535 because VERSIONINFO stores them as 16-bit words, and FAILS THE BUILD on anything else. A release built from a mistyped tag must not exist.- Without the variable the build is a development one,
0.0.0-dev(VERSIONINFO0,0,0,0), so a dev build is recognisable in the server’s access log and in the file properties. - One value reaches every place a version shows: the user agent, the
conflict report’s
installerfield, and all four VERSIONINFO fields, which come from a headerbuild.rswrites intoOUT_DIRfor the.rcto include. Cargo.toml’sversionis fixed at0.0.0with a comment saying the tag decides. Leaving a real-looking number there would invite someone to bump it.- CD releases when a
v/*tag is pushed, and names the GitHub release after that tag. The release on merge tomasterand the manual one both READCargo.tomland CREATE the tag; with nothing to read they go. - Verified by building, not by reading files: the test builds from a real tag and reads the version back out of the binary’s file properties and its user agent string, and checks that malformed tags fail the build.
§7 — the run log’s header
Support asks for three things first: when, which build, and which server. The log has none of them. A header at the top of each file, plus a timestamp per line.
D12 — /packages without /health
One dead endpoint must not declare the whole run offline. The catalogue is
the request that actually matters; /health is a convenience.
D12 — a cap on buffered bodies
http::get reads a whole body into memory with no limit. A manifest is
small, but that is the server’s promise rather than ours.
P11 — the writability probe
A game under C:\Program Files currently fails partway through the
install with an access error. Probe at the folder page and say so, with an
offer to restart elevated: a sentence before anything is written beats a
half-finished install.
Decided 2026-10-31:
- The probe runs when a route button is pressed — the moment the user
commits to the folder — not while they type. It creates
.pacenotes\and a file in it, and removes whatever it created: a folder the user then abandons keeps no trace. - Not writable → explain, and offer to restart as administrator (UAC, via
ShellExecuteWwithrunas). Declining, or cancelling the UAC prompt, leaves the user on scene 3 to choose another folder. - Already elevated and still not writable (read-only media, a deny ACL) → explain only; offering elevation again would loop.
- The elevated instance opens on scene 3 with the LANGUAGE, the FOLDER and the ACCESS KEY carried over, so nothing is asked twice. The key travels on the elevated process’s command line, readable by other programs running as this user — accepted by the maintainer for the seamless restart.
- The restarted instance waits for this one to exit rather than reporting “already running”.
- Files an elevated run installs belong to Administrators, so later runs on that folder need elevation too. Inherent to the choice; the probe will say so each time.
§7 — retrying a transient download
A single failed package ends the run. Retry with backoff, carry on with the others, and list what failed on scene 7.
Settled while building it (2026-10-31):
- Transient means no response at all (a connection or timeout failure), 408, 429 or 5xx, and a payload that arrived corrupted (its sha256 did not match). 401 and 404 will not change on a retry, and a local disk error is not the network’s fault, so those fail at once.
- Three attempts, waiting 1 s and then 3 s; Cancel is honoured during the wait. Each retry is a line in the run log, naming the reason.
- A package that still failed is left out of the run only when the cache has no copy of it. A failed UPDATE of an installed package keeps the installed version: unticking it would make the plan uninstall it, which a failed download must never do.
- Scene 7 names what could not be downloaded, and the log opens, as it does whenever something went wrong.
- When NOTHING could be downloaded the run ends at the summary as before, listing what failed: there is nothing new to configure.
- Tested through the real path: a stand-in in front of the real server fails one download once (it must arrive on the retry) and another for good (it must be left out and named on scene 7).
I9 — sweeping the cache
Decided 2026-10-31. An “orphan” by the cache index is not the same as unused: the index keeps only the newest version of each package, so after an update the previous version’s payload is unnamed by it — and that is exactly the payload a restore needs to roll the update back. Since the cache moved into the game folder, everything that can need a payload is in the same folder, so the sweep can know what is really in use:
- When the cache opens (a route is taken on scene 3), delete the payloads
named by NONE of: the cache index, the ledger, any restore point’s
snapshot. Plus leftover
.partfiles from a download that was killed. - If any of those cannot be read — a ledger or a restore point written by a
newer build, a damaged file — delete no payloads at all: what it needs is
unknown, and a wrong guess deletes someone’s way back.
.partfiles go regardless. - The run log says what was removed and how much space it freed.
- There is no “remove from cache” control: what nothing needs goes by itself, and what something needs must not go.
Things that are deliberately NOT done
Recorded so they are not rediscovered as oversights:
- Remembering the access key between runs (I8, and DESIGN-REVIEW §7’s DPAPI store). Decided by the maintainer on 2026-10-31: the key is typed on scene 2 each run and never stored. Scene 2 runs before a game folder is chosen, so a remembered key could only live in the user profile — the one exception “nothing outside the game folder” would have needed — and a key left on a shared or borrowed PC is worse than retyping it. The only time it travels is a restart as administrator (P11), on that process’s command line.
- Remembering the last game folder (I9). Decided by the maintainer on 2026-10-31, for the same reason as the key: it could only be kept in the user profile, outside the game folder. Scene 3 starts empty each run, except after a restart as administrator, which carries the folder over.
SetUnhandledExceptionFilterfor access violations and other faults that never reach a Rust panic hook. Revisit if a crash ever appears that leaves no panic line — that is exactly its signature.- Re-downloading a payload a restore needs but the cache no longer has. The partial restore ships first; the preview already names what cannot come back.
- Adopting a file the configuration step wrote that no package owns.
The step knows it wrote the file but not what was there before, and
claiming it would let a later uninstall delete something the user always
had. The consequence is accepted: a
PaceNote.iniwritten from the template stays after everything is uninstalled. Fixing it properly means the step must report created-versus-edited. - An install root for the three games other than RBR. Decided when
that game gets packages; BeamNG’s mods normally live under
%LOCALAPPDATA%, not beside the exe.
Server requirements
What server work has to satisfy. The RULES that constrain the code are in
server/CLAUDE.md; the protocol itself is in the root CLAUDE.md; how
work is verified is in the root requirements.md.
Publishing (manifest gen)
- Field precedence is sidecar > the previous
manifest.json> the protocol default, per field. That keeps a re-run idempotent — it can never downgrade metadata — while letting a publisher change one field by touching one line.size,sha256andupdated_atignore both and always come from the file. - A malformed sidecar is fatal, not a warning.
deny_unknown_fieldsplus a hard error means a typo such ascategoy = "sounds"can never publish a package with silently defaulted metadata. - Everything merely suspicious is a collected warning on stderr: a sidecar
with no zip, a dangling or ill-formed
requires, a public package requiring a protected one, a dependency in another game. - Warnings are returned as data (
Generated { manifest, warnings }) so they can be tested rather than only observed in a log. - The manifest is a pure function of zips, sidecars and schemas. A sidecar
without
versiongetssha-<first 8 hex of sha256>.
Serving
- A corrupt
manifest.jsonnever fails a request: log it and keep serving the last good copy. - Downloads stream, and
Content-Lengthcomes from the file handle rather than the manifest — a stale manifest must not be able to produce a truncated or over-long response. /packagesnever returns 4xx. An expired, revoked, unknown or malformed credential silently gets the anonymous listing, because status codes there would let a caller enumerate directory names./downloadresolves the package BEFORE deciding access, so a package that does not exist is 404 for everyone.TokenRow::is_liveis the single definition of “not revoked, not expired”. The listing filter and the download decision both call it, so they cannot drift apart.
Shape of the crate
serveris a lib plus a bin:src/lib.rsexports the modules,src/main.rsis only the CLI. Integration tests drive the real router and the real admin commands — a test that reimplements them proves nothing.- Credentials are identified on the CLI by the raw key or by the hash
(full, or the 12-character prefix
token listprints). An ambiguous prefix is an error, never a coin flip. token mintprints the key on stdout and the warning on stderr. It is the only place a raw key exists.- Grants are read per request and never cached, so
ungrantandrevoketake effect immediately. - base64url is twenty hand-written lines with known-vector tests, not a dependency.
Deploy
- Images are built in CI, never on the host. A
v/X.Y.Ztag runs.github/workflows/cd-release-on-tag.yml, which pushesghcr.io/<owner>/pacenotes-server:X.Y.Z(and:sha-<12>) as a PRIVATE package — never a release asset or workflow artifact, because the repository is public and so are its artifacts — then SSHes to the host to rundeploy/pacenotes-update X.Y.Z. - The Quadlet runs the LOCAL tag
localhost/pacenotes-server:currentwithPull=never; onlypacenotes-updatemoves that tag. A restart therefore never builds, never pulls and never changes the version, and works with the registry down. pacenotes-updateis the deploy key’s forcedcommand=. It takes ONLY anX.Y.Zversion fromSSH_ORIGINAL_COMMAND, pulls that exact image with the host’s ownread:packagestoken, keeps the running image as:previous, restarts, and waits for the healthcheck; an unhealthy container is rolled back to:previousand the script exits 1, failing the CI job.- Not
podman auto-updateand noAutoUpdate=: it cannot be scoped to one unit, and other containers on the host carryAutoUpdate, so every release would have updated them too. podman untagneeds the image AND the name: given a name alone it strips every name the image has,:currentincluded, and the prune that follows deletes the running image. Found by the script’s test, 2026-11-01.podman image prune -fwithout--external: the--externalform deletes buildah’s intermediate containers, killing any concurrent build.StartLimitIntervalSec=1hwithStartLimitBurst=3: a crash loop stops instead of restarting forever.CARGO_BUILD_JOBS=1is the Containerfile’s DEFAULT, for a build on the 3 GB host, where an OOM presents as a vanishing-layer error rather than an honest message. CI passesCARGO_BUILD_JOBS=4.libsqlis built without itsreplicationfeature. Embedded replicas are not something this server creates, andcore+remote+tlsstill satisfy “moving auth to Turso is a--db-urlchange”.- The container binds
0.0.0.0:8080and publishes no host port: Caddy terminates TLS and is the only route in. entrypoint.shrunsmanifest genbeforeserve. A failed generation does not stop the server; it serves the previous manifest.
Design decisions worth not relitigating
gamecomes from the folder level, not a sidecar key or a query parameter — one source of truth, and the operator’s layout is the thing they already maintain. Moving a package between games is amv, which is the point.- The game leads the download URL. With one server,
public/basiccan exist under every game, so a game-less path would be ambiguous and resolving it by first match would silently serve another game’s bytes. /packageswithout?gamereturns every game, because the installer is a multi-game wizard and one request is cheaper than four. The filter exists for clients that care about one.- Names may contain spaces (
style/Numeric Standarddoes). That is legal — it only has to survive a URL — somanifest genwarns and publishes rather than refusing. Clients must percent-encode. - A schema change needs an installer release first. Ship an installer that accepts both the old and the new hash, then switch the server’s file. Switching the server first blocks every installer in the wild.
Design review: operational ergonomics, pitfalls, footguns
Date: 2026-10-24. Reviewed at commit 06dfc91 on master.
Scope: root, installer/ and server/ CLAUDE.md files, installer/DESIGN.md,
the three TASKS.md files, deploy/, .github/workflows/, and all code in
shared/, server/src/ and installer/src/.
No code was changed. Findings marked Reproduced were confirmed by running
the existing pacenotes-server debug build against a scratch data tree. The
rest come from reading the code and the documents.
Severity:
- Critical: silent data loss, or a core feature that does not work.
- High: wrong behaviour that users or the operator will run into.
- Medium: a latent problem, or friction in day-to-day operation.
- Low: polish.
Each finding has an ID. D = a defect in the current code. P = a pitfall in the install model. S = server, protocol or publishing. C = multi-server or cloud. O = deployment and CI. U = support and observability.
1. Summary
This document is the review, not the todo. The findings below are kept as written, with how each was resolved; the work that is still OPEN lives in the task lists, where work is picked up:
| Still open | Tracked in |
|---|---|
| Installer, before release | installer/TASKS.md → “Pre-release hardening” |
| Deploy, CI and supply chain | TASKS.md → M7 |
| Multi-server (period 3) and the backlog | section 10 of this file |
Status, last checked 2026-10-27: “Fixed” means the code changed and a test covers it.
| ID | Finding | Severity | Status |
|---|---|---|---|
| D1 | Packages marked “(update)” are never downloaded, so a republished package never reaches anyone who has the old one cached | Critical | Fixed |
| P1 | The tree ticks packages based on the per-user cache, but Apply compares against the per-folder ledger. Anything installed but not cached gets uninstalled silently | Critical | Fixed (I7) |
| P3 | Skipping the backup for byte-identical files makes the installer the owner of files the user already had. Uninstalling then deletes them, and there is no backup to restore | Critical | Fixed (I7) |
| P6 | If an install fails partway, the next run backs up the half-written files as if they were the user’s originals | Critical | Fixed (I7) |
| D2 | An invalid access key can never be detected, because /packages never returns 401 | High | Fixed both sides |
| D3 | /download reveals which protected packages exist (401 vs 404). Reproduced | High | Fixed |
| D5 | Metadata removed from a sidecar stays in the manifest. A rebuilt manifest differs from the published one, and replicas drift apart. Reproduced | High | Fixed |
| P2 | Uninstall deletes ledgered files even after Steam, RSF or the user has replaced them | High | Fixed (I7) |
| P5 | Ledger paths are compared byte-for-byte, but NTFS ignores case | High | Fixed (I7) |
| C1 | Redirecting downloads to object storage or a CDN would break the current client: WinHTTP follows redirects, probably with the Authorization header | High | Open — see the table above |
| S4 | Publishing is not atomic: a download can stream a half-copied zip, and replacing a zip in place makes every client fail its sha check | High | Minimal fix done (size mismatch → 503); blobs still Period 3 |
| O1 | Every restart, including recovery after a crash, rebuilds the image from the branch tip. Publishing content therefore also deploys code | High | Open — see the table above |
| D10 | The server never shuts down gracefully in the container. SIGTERM is ignored (PID 1), and podman force-kills it after its stop timeout | Medium | Fixed |
| D4 | manifest gen reports false cross-game requires warnings because Manifest::get ignores the game. Reproduced | Medium | Fixed |
| D6 | Cache::save deletes the old file and then renames. A crash in between empties the cache index, and the planned orphan sweep would then delete every download | Medium (High once I9 lands) | Fixed |
| D8, D9 | The auth row decoder fails open. A DB error returns 500 even for public downloads | Medium | Fixed |
| D11 | Production has no access logs at all | Medium | Fixed |
2. Defects in the current code
D1: Updates are never downloaded (Critical)
- Where: installer/src/tree.rs:56-61, tree.rs:366-372 and wizard.rs:1163.
- What happens:
LeafState::is_present()returns true forUpdateAvailable, andchecked_needing_download()skips every leaf that is “present”. As a result, a leaf marked “(update)” is ticked but never fetched. The cache index keeps the old sha, so once I7 exists it will install the old bytes again. There is no way for the user to force a refresh. - Test: the test in installer/tests/tree.rs:298-303
says “so it must refetch”, but it only asserts that
is_present()is true. - Fix: split the predicate in two:
has_payload()for “installable offline”, andneeds_download()forNotCached | UpdateAvailable. Add a test asserting that an update leaf shows up inchecked_needing_download().
D2: An invalid access key cannot be detected (High)
- Where: installer/src/worker.rs:217-227, server/src/routes.rs:56-78 and installer/DESIGN.md:53.
- What happens: scene 2 validates the key by calling
GET /packagesand treats a 401 as “invalid secret”. The server never returns 401 there: by design, any bad, expired or revoked key gets the public listing. SoSecretOutcome::Rejectedis dead code. A mistyped key looks accepted, and the user just sees public content with no explanation. The same thing happens when a valid key expires. - Fix: add a
GET /whoamiendpoint, or a header on/packages, returning{ valid, expires_at }for the presented key. This tells a caller nothing they could not already know: a 256-bit key cannot be guessed, and the response names no directories. It also lets the installer warn a few days before a key expires.
D3: /download reveals which protected packages exist (High, Reproduced)
- Where: server/src/routes.rs:90-102, and the opposite claim in CLAUDE.md:122.
- Reproduced: with no credential,
/download/rbr/premium/codriver/Secretreturned 401, while/download/rbr/premium/codriver/Nopeand/download/rbr/nosuchdir/xreturned 404. - Why it matters: an anonymous caller can enumerate both directory names
and package names. That undoes the reason
/packagesfilters silently. - Fix: for a directory other than
public, return 404 to any caller without a grant for it. If you want to keep a distinct signal, return 401 when there is no live token at all, whether or not the package exists. Then update the integration matrix inserver/tests/api.rs.
D4: False cross-game requires warnings (Medium, Reproduced)
- Where: server/src/store.rs:508 and shared/src/lib.rs:305-309.
- What happens:
Manifest::get(&PackageRef)matches on directory and name only. When bothbeamngandrbrpublishpublic/language/polish, anrbrpackage that requires it resolves to thebeamngone, becausebeamngsorts first.manifest genthen warns(game rbr) requires public/language/polish (game beamng). The public-vs-protected check runs against the wrong package too. - Footgun: the I7 engine will need
requiresresolution, andManifest::getis the obvious helper to reach for. - Fix: replace it with
Manifest::get(game, &PackageRef), or resolve refs withinpackage.gameonly. Document thatrequiresrefs are scoped to the package’s own game.
D5: Metadata cannot be removed, and the manifest cannot be rebuilt (High, Reproduced)
- Where: server/src/store.rs:327-359.
- Reproduced: after
Kamil.toml(which heldrequires = [...]) was deleted andmanifest genwas run again, the manifest still carried therequires. - What follows:
- Removing
requires,exclusive_group,display_nameordescriptiontakes an undocumented step: deletingmanifest.json. - Deleting
manifest.jsonresets every version that was not in a sidecar to0.0.0and drops every display name that only existed in the old manifest. deploy/README.md:235 calls the manifest “reproducible”, but it is not. - Two replicas that generate their own manifests from different histories publish different metadata.
- Removing
- Fix: make the manifest a pure function of zips plus sidecars. Make
versioneither required, or derived (for example fromupdated_ator a short sha). A one-offmanifest migrate-to-sidecarscommand can write the current sticky values into sidecars once.
D6: Crash window when saving the cache index, then the sweep deletes everything (Medium, High once I9 lands)
- Where: installer/src/cache.rs:52-59 and cache.rs:109-123.
- What happens:
save()deletescache.jsonand then renames the temp file into place, citing “Windows rename fails onto an existing file”. That is not true for Rust:std::fs::renameon Windows replaces the target (MoveFileExW withMOVEFILE_REPLACE_EXISTING). The delete only opens a crash window.Cache::opentreats a missing or unparseable index as empty, so every payload becomes an orphan, and the planned I9 sweep deletes orphans. - The same happens when a newer installer writes an index that an older one cannot parse.
- Fix:
- Drop the
remove_fileand rename straight over the old file. - Add
"version"toIndex. - Let
opentell three cases apart: missing, unreadable and parsed. - Never sweep after an unreadable index. Only sweep orphans older than N days.
- Drop the
- The ledger (I7) is meant to copy this temp-and-rename pattern, so fix the pattern first.
D7: No single-instance guard (Medium)
- Where: installer/src/api.rs:102-106.
- What happens: the
.partfile is named after the package. The comment says this stops two runs from sharing a temp file, but two instances downloading the same package write the same.part.cache.json.tmpis shared as well, andcache.jsonis last-writer-wins. Once I7 exists, two instances aimed at one game folder will also race oninstalled.json. - Fix: a named mutex (
CreateMutexW, costs nothing in size), checked at startup with a “the installer is already running” modal.
D8: The auth row decoder fails open (Medium)
- Where: server/src/auth.rs:133-139.
- What happens: the code reads
revoked: row.get::<i64>(3).unwrap_or_default() != 0, andexpires_atfalls back toNonewhen the read fails. After a schema or type drift, a revoked key reads as live and an expiring key reads as permanent. - Fix: an authorization path should fail closed: propagate the error and return 500.
D9: A DB outage breaks public downloads (Medium)
- Where: server/src/routes.rs:14-26, routes.rs:68 and routes.rs:97.
- What happens:
caller()looks up the token beforedecide()for every package, public ones included. If the DB errors (a remote Turso blip, or a locked SQLite file), any request that carries a bearer token gets 500, even for public content or the public listing. - Fix:
- Skip the lookup when
package.directory == "public". - On
/packages, fall back to the public listing on a DB error and log it loudly. - Reject tokens that are not shaped like
pcn_plus 43 characters before querying, so garbage headers cost no DB round trip. On remote Turso, every query costs money.
- Skip the lookup when
D10: Shutdown is never graceful in the container (Medium)
- Where: server/src/main.rs:114-118 and deploy/entrypoint.sh.
- What happens:
- The server only listens for
ctrl_c(SIGINT). execmakes it PID 1 in the container. The kernel drops signals sent to a namespace’s PID 1 unless it has a handler for them.- So
podman stopsends SIGTERM, nothing happens, and after the stop timeout (10 s by default) podman sends SIGKILL. - Every deploy is therefore slow and cuts in-flight downloads hard.
- The server only listens for
- Fix: also handle SIGTERM with
tokio::signal::unix. Bound the drain: axum waits for every open connection, and a 40 MB download on a slow link can take minutes, so add a timeout of about 20 s. SettingStopSignal=SIGINTin the Quadlet is a stopgap.
D11: No access logs in production (Medium)
- Where: server/src/routes.rs:42 and deploy/Caddyfile.snippet.
- What happens:
TraceLayer::new_for_http()logs requests and responses at DEBUG, and the deployment runs atRUST_LOG=info. Caddy’slogdirective is only on the internalhttp://block. Questions like “who downloaded X”, “how many 404s since the last publish” or “which key is being hammered” cannot be answered. - Fix: log one INFO line per response with method, path, status, bytes,
duration, a 12-character token-hash prefix (never the token) and the
user agent. Enable
logon the HTTPS site in Caddy.
D12: Smaller defects (Low)
- installer/src/wizard.rs:729 discards the
catalogue error (
Err(_)). The cause (TLS, 5xx, parse) never reaches the run log, which is the only diagnostic the user can send you. - The cache index (
tree::CachedPackage) stores neitherexclusive_groupnorrequires, although installer/CLAUDE.md:46 says it does. Offline, Apply cannot enforce either.sizeandupdated_atare missing too. - installer/src/wizard.rs:775: one failed
/healthat scene 2 makes the whole run offline, even when/packageswould have worked (a load balancer hiccup, or a 10 s timeout). Try/packagesanyway, and use/healthonly for the status line. - server/src/routes.rs:128-131:
Content-Dispositionuses the fullname, with/, spaces and perhaps". The docs say it names the leaf. Use the leaf and RFC 6266filename*=UTF-8''…. - installer/src/zip.rs:172-213: the
central-directory loop stops quietly at the first unexpected signature and
never checks the entry count from the EOCD record. A truncated directory
yields a partial file list, which becomes a partial ledger. Fail when
entries.len() != count. shared::valid_nameaccepts control characters. The RBR configuration step will derivesounds=from the name’s segments, so a name containing\r\ncould inject lines intoPaceNote.ini. Reject bytes below 0x20 invalid_name. It is a one-line change inshared, and both sides benefit.http::getbuffers the manifest and the MOTD with no size cap, so a hostile or misconfigured server can exhaust memory. Cap it at a few MB.
3. Install model pitfalls
I7 has not been built yet, so this is the cheapest moment to change the ledger schema. Most of these reduce to one decision: the ledger has to record more than a list of paths.
Suggested shape for a ledger entry:
"rbr/public/codriver/polish/male/Kamil": {
"sha256": "…", "version": "…", "exclusive_group": null,
"state": "installed", // or "installing" (write-ahead, P6)
"files": {
"plugins/pacenote/pacenote.ini": { // case-folded key (P5)
"path": "Plugins/Pacenote/PaceNote.ini", // as written
"sha256": "…", // what we wrote (P2)
"prior": "backup" | "identical" | "absent" // what was there before (P3, P4)
}
}
}
P1: Ticks come from the cache, removals from the ledger (Critical)
- What happens:
Tree::buildticks a leaf by default only when its payload is in the per-user cache. The I7 plan will uninstall whatever is “ledgered but not selected”. The cache and the ledger are independent: the cache is per Windows user and can be cleared, while the ledger lives with the game folder. Every run where they disagree uninstalls things the user never touched. Cases where that happens:- a cleared cache, or a second Windows account;
- the game folder copied to another PC;
- a package the operator withdrew, or a grant that was revoked. If the package is not cached, it is not in the tree at all, so the user cannot even keep it. A server-side change silently uninstalls content from users’ games.
- Fix:
- Merge the ledger into the tree as its own state, “installed”, and tick those leaves by default.
- A ledgered package missing from both the server and the cache still appears as “installed (unavailable)”, so the user can keep it or remove it.
- Before phase 2, show a confirmation that lists what will be removed. Removal should never be a side effect the user cannot see.
P2: Uninstall deletes files that are no longer ours (High)
- What happens: uninstall means “delete the ledgered files”. Others write into game folders too: Steam’s “verify integrity” and game updates, the RSF installer, plugin updates, other mod managers, and users editing ini files by hand. Once any of them replaces a file, uninstalling deletes their version.
- Fix: store the sha256 of what was written. On uninstall, delete a file only if it still matches, and otherwise leave it and log it. Restore needs the same comparison. The cost is a hash pass over the package files at uninstall, which is cheap next to extraction.
P3: An identical-file skip turns pre-existing files into installer-owned ones (Critical)
- What happens:
- The user installed FilipekMod by hand.
- The installer installs the same package. The backup is skipped because the bytes match, and the files enter the ledger as the package’s own.
- The user switches voice or uninstalls. The files are deleted, there is no backup, and the user’s earlier setup is gone.
- The skip is the right call for disk space. Ownership just needs to remember what was there before.
- Fix: record
prior: "identical"for each such file. On uninstall, restore its content instead of deleting it. The bytes are already in the cached payload (<sha>.zip, at a known entry path), so this costs a reference instead of a copy. The sweep has to treat payloads referenced this way as pinned.
P4: Uninstall should put things back, not just delete (High)
-
What happens: under the current plan, uninstall deletes files and Restore is a separate global action. Switching a voice off therefore removes a
PaceNote.inithat existed before the installer ran, and the Pacenote plugin is left without config until the user finds Restore. -
Fix: handle each file according to its
priorvalue:backup: copy the backup back and delete the backup copy;identical: restore from the cached payload;absent: delete.
Uninstalling then returns each file to its state before the installer touched it. The global Restore becomes a rarely needed “undo everything”, and backups stop going stale (see P14).
P5: Paths are case-insensitive on Windows (High)
- What happens: ledger keys are compared byte-for-byte. Packages from
different authors and tools disagree on case (
Plugins/Pacenotevsplugins/pacenote), and NTFS treats both as the same file. Ownership (“does another entry still list this file?”) then gives the wrong answer, and an uninstall deletes a file that another package still needs. - Related cases:
- Windows strips trailing dots and spaces (
foo.andfooboth becomefoo). - Reserved device names (
CON,NUL,AUX,COM1,LPT1,aux.ini) open devices instead of files. - A zip can contain the same path twice.
- Windows strips trailing dots and spaces (
- Fix:
- Key the ledger on a case-folded form and keep the original spelling for display.
- In
zip::safe_relative, reject segments with a trailing dot or space and reserved device names. - Remove duplicate entries before extracting.
P6: Partial installs poison the backup (Critical)
- What happens: uninstall runs before install, which is correct for the
49 shared style paths. If extraction then fails (disk full, a file locked
because the game is running, a CRC error halfway), two things go wrong:
- the user ends up with neither the old package nor the new one;
- the half-written files are not in the ledger, because the ledger is written after each package succeeds. On the next run they look user-owned, so they get backed up as the pristine originals. Restore would then put the broken files back.
- Fix:
- Pre-flight before touching anything: inflate and CRC-check every entry of every package to be installed (or check that it was verified since it was cached), check free space for the installs plus the backups, probe that the folder is writable, and check that the game is not running.
- Write-ahead: set the entry to
state: "installing"with its planned files before extracting. A run that findsinstallingon start knows those files are its own, finishes or rolls back, and never backs them up. - Write each file to a temp name and rename it, so a crash never leaves a truncated game file.
P7: Overlaps between packages from different categories (Medium)
- What happens: the rule “delete only if no other entry lists it” keeps
the file but leaves the last writer’s bytes in it. If a language pack and a
voice pack both ship
PaceNote.ini, removing the voice leaves the voice’s version of the file, not the language pack’s. - Fix: at plan time, find every file that more than one selected package
would write. Either refuse with a clear message (the operator should use
exclusive_group), or keep an ownership stack and restore the previous owner’s bytes from its cached payload. Refusing is simpler and makes these conflicts visible.
P8: Two exclusivity mechanisms, one of them unimplemented (Medium)
- What happens: exclusivity comes from two places. Scene 5 enforces one
pick per
CHOICE_CATEGORY.exclusive_groupis in the protocol, and DESIGN.md says “the UI radio-selects within a group”, but nothing intree.rsorchoices.rsimplements it. Two checkedpacenotesextras in the same group would both install, over each other. - Fix: decide on one rule, for example “a category in
CHOICE_CATEGORIESis an implicit group;exclusive_groupcovers everything else”. Then either implement the radio behaviour on scene 4, or have Download refuse more than one member of a group.
P9: RBR’s categories are hard-wired for every game (Medium)
- What happens:
CHOICE_CATEGORIES(language, codriver, style) sits insharedand drives scene 5 for all four games. A BeamNG run would ask three pacenote questions with empty, disabled lists. - Second problem: scene 4 requires a pick in every top-level category
present. If the operator creates a new folder such as
extras/, every user must tick something in it on their next run, so a content change changes the mandatory flow of the UI. - Fix: move the choice categories into a per-game table (
gamedir.rs, or a small per-game declaration in the manifest). Only choice categories should be mandatory; everything else should be optional. This also fits the rule that each game owns its own step.
P10: Is the game root the right install root for every game? (Medium)
- BeamNG mods normally live under the user folder
(
%LOCALAPPDATA%\BeamNG.drive\<version>\mods), not next toBeamNG.drive.exe. - Check each game before fixing the overlay root at “the folder that holds the marker”. A declarative per-game install root still involves no scripts.
P11: Folder permissions and UAC (Medium)
- What happens: the manifest asks for
asInvokerand hastrustInfo, so Windows does not virtualize writes. A game underProgram Files(older RBR installs, some retail copies) fails with “access denied” in the middle of phase 2. - Fix: at scene 3, try to create
.pacenotes\and delete it again. If that fails, explain why and offer to restart elevated (ShellExecuteWwith therunasverb), or stop with a clear message.
P12: The game is running (Medium)
A running game holds its files open, and writes fail with a sharing violation partway through the install. Check for the marker process, or try opening the files exclusively, before phase 2. This belongs in the P6 pre-flight.
P13: A package rename changes its identity (Medium)
- What happens:
game/directory/nameis the identity of a package. Fixing a typo in a folder (PolsihtoPolish), moving a package betweenpublicandpremium, or regrouping levels all create a new package. The consequences:- caches show the old one as “(local only)”;
- ledger entries are orphaned, and the user downloads everything again;
- the RBR step’s
sounds=path changes.
- Fix: document it as a breaking operation in
deploy/README.md. Optionally, add a sidecar keyreplaces = ["public/codriver/polsih/male/Kamil"]so the installer can carry ledger and cache entries across a rename.
P14: The restore design is not settled (Medium)
- installer/DESIGN.md:74 describes “a list of selectable previous backup packages”, but the model has one first-touch backup tree.
- To be truly “pristine”, a restore also has to delete the files packages added and clear the ledger. Both depend on the ledger existing.
- First-touch backups never expire. A backup from 2026 restored after a 2027 plugin update rolls the plugin back. Show backup dates, and with P4 most backups are consumed on uninstall anyway.
P15: Memory use (Low)
A zip is read whole, and each entry is inflated whole. That is fine at 44 MB, but voice packs grow. Seeking per entry in the cached file keeps the peak at the size of one entry.
4. Server, protocol and publishing
S1: No protocol version and no client version (High)
- What happens:
Manifesthas no schema version, the user agent is hard-coded asPacenotes-Installer/0.1, and there is no minimum supported client and no update channel. Old.exefiles live on for years, so the next protocol change will break them without any message. - Fix:
- Add
schema: u32,generated_at, agenerationhash andmin_installer: Option<String>toManifest. - Take the user agent from
CARGO_PKG_VERSION. - Have the installer show “please download the new installer” when it is too old.
- The server logs the user agent (D11).
- Add
S2: The server is trusted for everything, including code (Medium; High for multi-host)
- What happens: the sha256 that protects a download comes from the same server as the zip. Overlay zips can write DLLs into the game folder, so a compromised server or mirror gets code execution inside the game process. The “no scripts in packages” rule protects the installer, not the user.
- Fix: sign the manifest with ed25519 and embed the public key in the installer. Mirrors, CDNs and object storage then cannot tamper with packages. Measure the verifier against the 500 kB budget first; a compact ed25519 implementation is small.
S3: Folder names are untranslated UI labels (Low)
Level labels such as codriver, male and polish show as raw lowercase
folder names in both the English and the Polish UI. An optional per-level
label map (for example _labels.toml in a folder, merged into the manifest)
would keep the layout as the single source of truth and allow localized
labels.
S4: Publishing is not atomic (High)
- What happens:
cp zip …writes in place. A download that starts during the copy streams a partial file. Content-Length comes from the file, so the client gets a short body with a matching length, fails the sha check and reports “arrived corrupted”.- Replacing a zip in place before
manifest genruns makes every client fail verification until it does. - server/src/routes.rs:115 deliberately trusts the file over the manifest, which hides this.
- Fix, best option: content-addressed blobs. Store
data/<game>/blobs/<sha256>.zip, have the manifest point to the sha, and keep the folder tree as the authoring view, whichmanifest genhashes and copies into blobs. Published bytes then never change. - Fix, minimal option: document “copy to
*.tmp, then rename”. In the handler, return 503 and log an error whenfile size != package.size.
S5: manifest gen problems (Medium)
- It hashes every zip on every start. Reuse the previous sha when the path, size and mtime are unchanged.
- It writes
manifest.jsonwithstd::fs::write, which is not atomic. Write to a temp file and rename it. - A single broken sidecar fails the whole run. The entrypoint swallows the
failure (
|| echo), so the catalogue silently stays on the previous manifest and nothing reports it. Skip the broken package with an error, exit non-zero, and expose the state on/ready(S6). - Add
--check/--dry-run: print the diff against the current manifest and the warnings, without writing anything. Add--strict, which fails on warnings, for publish pipelines.
S6: /health returns “ok” even with nothing to serve (Medium)
Add /ready, reporting whether the manifest loaded, the package count, the
generation or generated_at, and whether the DB is reachable. Orchestrators,
uptime checks and you can then tell “up” from “serving an empty catalogue”.
The Quadlet HealthCmd acts on nothing today, because no HealthOnFailure=
is set.
S7: SQLite settings and migrations (Medium)
- There is no
journal_mode=WALand nobusy_timeout. Apodman exec … token mintwhile the server is reading can returnSQLITE_BUSY, which surfaces as a 500 to a user. PRAGMA foreign_keysis off by default, soON DELETE CASCADEdoes nothing. Nothing hard-deletes tokens yet, so this is latent.- Migrations are re-applied on every start because the statements are
idempotent. The first
ALTER TABLEwill break that, and with several replicas every instance runs migrations concurrently. Track the version withPRAGMA user_versionor aschema_migrationstable, and make one process (the CLI) responsible for migrating. - One shared
libsql::Connectionserializes every request. For the remote backend, check stream reuse under concurrency. Keeping theDatabasehandle and opening a connection per request is the safer pattern.
S8: Operator visibility into credentials (Low)
There is no last_used_at and no per-key download count, so key sharing on
paid content cannot be noticed. token list could show both. Add a
token rotate (mint, copy grants, revoke the old key) to make rotation one
command.
S9: Backups (Medium)
- Copying
auth.dbwhile the server runs can produce an inconsistent copy. Usesqlite3 auth.db ".backup …"orVACUUM INTO, or add atoken exportcommand. - deploy/README.md:235 says packages and
manifest.jsonare reproducible. Packages are operator content and need a backup or another authoritative copy, and the manifest is not reproducible either (D5).
5. Running on several servers or in the cloud
What ties the current design to a single host:
| Coupling | Where | Breaks when |
|---|---|---|
One mutable volume holds auth.db, packages and manifest.json | Quadlet Volume= | two instances share it: SQLite over a network filesystem is unsafe |
Every instance runs manifest gen at startup | entrypoint.sh | instances write one file at the same time, or each produces its own manifest (D5) |
Reload is triggered by the mtime of manifest.json, checked by a blocking stat on every request | store.rs:78-114 | object-storage FUSE mounts and some network filesystems report mtime poorly; stat is slow on them |
Packages are served by tokio::fs::File::open(path) | routes.rs:104 | packages live in object storage |
| No cache headers on any response | routes.rs | a CDN goes in front (C2) |
| The MOTD is read from disk on every request | store.rs:161 | there is no shared disk |
A target that keeps the current simplicity:
- Stateless instances. Auth moves to remote libSQL or Turso. The code
already supports it, since
--db-urlis the only change. Embedded replicas would give local-latency reads, but they need libsql’sreplicationfeature, which was dropped to fit a build on the 3 GB host. Building in CI (O1) removes that constraint. - Publishing becomes a job, not a side effect of startup.
manifest genruns once, wherever the content is authored (CI or an admin box). It uploads the content-addressed blobs (S4) and the manifest with itsgenerationid (S1). Instances load the manifest from storage on a timer or on SIGHUP and swap it in withArc(no per-request clone). - Downloads. Public blobs are immutable, so they can come straight from a
CDN with
Cache-Control: public, max-age=31536000, immutable. Protected blobs are authorized by the server, which then redirects to a short-lived signed URL. - A server-side
PackageSourceabstraction (local filesystem, object store, redirect), soroutes.rsstays the same. This belongs on the server only;shareddoes not change.
C1: The installer must handle redirects itself before v1 ships (High)
- What happens: WinHTTP follows redirects automatically. As far as I
know, it re-sends headers added with
WinHttpAddRequestHeaders, includingAuthorization. Verify this with a test. If so, redirecting to a signed URL would:- send the user’s access key to the storage provider;
- fail anyway, because S3 and compatible stores reject a request that
carries both a pre-signed query and an
Authorizationheader (400).
- Why now: installers already in the wild cannot be patched. Once one release ships with automatic redirects, the server can never start redirecting.
- Fix: disable automatic redirects (
WINHTTP_OPTION_DISABLE_FEATUREwithWINHTTP_DISABLE_REDIRECTS), follow at most N hops by hand, allowhttpsonly, and dropAuthorizationwhenever the host changes. This is a few dozen lines inhttp.rs.
C2: Cache headers, before any CDN goes in front (High)
/packagesvaries by credential. SendCache-Control: private, no-store, or at leastVary: Authorization. Otherwise a shared cache can serve one key holder’s listing to everyone.- Protected
/downloadresponses:Cache-Control: private. The URL is the same for every caller and authorization is only a header, so a “cache everything” rule would hand premium zips to anonymous users. - Public downloads:
ETag: "<sha256>". - Add
Accept-Rangesand Range support, plus resume in the installer. Without them, a large voice pack that drops at 90 % restarts from zero.
C3: Hostname and fallback (Medium)
- A release build compiles in
PACENOTES_DEFAULT_SERVERfrominstaller/.env(installer/build.rs), so a shipped binary carries one hostname for good. Moving hosts only works through DNS, so keep that name under the project’s control. Never ship a provider hostname (*.fly.dev,*.azurewebsites.netand the like). - Optional: a second hostname as a fallback, tried when the first is unreachable.
C4: Abuse and cost (Medium)
- Cloud egress is billed, and
/downloadhas no rate limit. Add per-IP limits at Caddy, the CDN or the server. - Reject malformed bearer tokens before the DB lookup (D9). On remote Turso, every lookup is billed.
- Logs need to be structured (a JSON option) so a log aggregator can parse them.
6. Deployment and CI
O1: Every start of the Quadlet runs a build (High)
- Where:
ExecStartPrein deploy/pacenotes.container runsgit cloneandpodman buildfrom the tip ofBRANCH_TO_BUILD. WithRestart=on-failure, that includes recovery after a crash. Consequences:- Recovering from a crash takes minutes, because it rebuilds first.
- Whatever is on
masterat that moment is deployed, without a decision. - If the git host, crates.io or Docker Hub is down, the server stays down after a reboot.
- The README’s publishing path (“drop the zip in, restart”) also deploys new code.
- Builds run on the 3 GB production host, next to the services it runs, and have already caused OOM kills.
- Every image is tagged
latest, so there is nothing to roll back to.
- Fix (M7 already lists part of this): CI builds the image, tags it with
the git sha and pushes it to a registry. The Quadlet pins that tag, so a
restart never builds. Rolling back means pinning the previous tag. Publish
content with
manifest genalone (S5).
O2: Smaller deployment and CI issues (Low to Medium)
- deploy/pacenotes.container:45
hard-codes
/home/user/.config/...forEnvironmentFile, while the README installs to~/.config/.... - deploy/Caddyfile.snippet:18 binds the
plain-HTTP site to
0.0.0.0. If the Caddy container publishes port 80 (it usually does, for ACME), bearer tokens can travel unencrypted from outside. Bind it to the container network or loopback only, or remove it. - Large data tree: the
:Zvolume option relabels the whole tree on every start, which gets slow as packages accumulate. ci.ymlruns onmaster, but the repository’s main branch is reported asmain. Check that pull requests into the default branch actually trigger CI.- The toolchain floats (
dtolnay/rust-toolchain@stable,rust:1-slim-bookworm), and CI uses-D warningsand a size gate. A new rustc or clippy release can fail CI, or move the binary size, without any commit. Pin the toolchain inrust-toolchain.tomland pin base images by digest. Actions are pinned by tag, not by sha. - The release
.exeis unsigned. A small binary that downloads files and writes them into a game folder is a typical SmartScreen and antivirus heuristic hit. Sign it with Authenticode in the CD workflow, and publish its SHA-256 alongside the release. - The server has no versioned artifact, release or tag (an M7 item).
7. Support and observability (installer)
- Run log (installer/src/runlog.rs):
- Lines have no timestamps, and the log has no header with the installer
version, server URL, manifest
generation, Windows version and game folder. Those are the first things support asks for. - The log is overwritten on every run, so a user who reopens the
installer to “see what happened” erases the failed run. Keep the last
three (
install.log,install.1.log, and so on).
- Lines have no timestamps, and the log has no header with the installer
version, server URL, manifest
- Correlation: send an
X-Request-Id, or at least a versioned user agent, and log it on both sides so a user’s log can be matched against server logs. - Storing the secret: I8 plans a plain file. Use DPAPI
(
CryptProtectDatafromcrypt32; no crate needed) so the key is not readable by other users or by malware scanning text files. - Error recovery: a single failed package aborts the whole run (installer/src/worker.rs:298). Retry transient errors with backoff, carry on with the other packages, and list the failures on scene 7.
- One TLS handshake per request:
get_streamingopens a new WinHTTP session each time. Reusing one session for the whole run saves a handshake per package. This matters little now but adds up with dozens of packages. - Free space: I9’s check should count downloads, plus extracted size, plus backups.
8. Documentation that disagrees with the code
| Document | Says | Actually |
|---|---|---|
| CLAUDE.md:122 | “the status never reveals what exists” | it does: 401 vs 404 (D3) |
| installer/DESIGN.md:53 | /packages returns 401 for an invalid secret | it never does (D2) |
| installer/DESIGN.md:74 | restore offers “a list of previous backup packages” | the model has one backup tree (P14) |
| installer/CLAUDE.md:46 | cache.json stores size, updated_at, exclusive_group, requires | it stores none of them (D12) |
| server/CLAUDE.md:103 | Content-Disposition names the leaf | it uses the full name (D12) |
| deploy/README.md:235 | packages and manifest.json are reproducible | neither is (D5, S9) |
| installer/src/cache.rs:116 | “Windows rename fails onto an existing file” | not true for std::fs::rename (D6) |
| installer/src/api.rs:102 | “.part names stop concurrent runs sharing a file” | they share it when both download the same package (D7) |
| installer/tests/tree.rs:298 | “so it must refetch” | the test asserts the opposite (D1) |
| server/TASKS.md:65 | sidecar categories sounds/configs/languages/pacenotes | categories come from paths now; a category key is rejected |
| server/CLAUDE.md:76 | “Security invariantsr” | typo |
| deploy/pacenotes.container:45 | /home/user/.config/... | the README uses ~/.config/... |
9. What is solid (keep it)
- Names and directories are lookup keys, never path fragments, and are checked
on both sides (
valid_id,valid_name). Traversal is ruled out by construction. - The zip-slip guard (
safe_relative) is plain string logic covered by string tests. CRC checks at extraction catch a cache that has rotted on disk. - A download goes to a temp file, is hashed while streaming, and is promoted only on a match. Nothing unverified enters the cache.
- Raw tokens are never stored or logged.
is_liveis defined once.token listshows a short hash. deny_unknown_fieldson sidecars, with a hard failure.- Each game owns its configuration step, with no shared abstraction. RBR’s
step edits
PaceNote.iniinstead of regenerating it. - The test discipline: real fixtures, and assertions against the source of
truth (the
.rcstrings).
10. Task list
Every finding in sections 2–8 appears below at least once, under the period by which it should be handled. The IDs point back to the findings, where the reasoning is. A fourth period, Backlog, holds the low-priority items that have no deadline.
Protocol changes follow the root CLAUDE.md rule: change shared/ first,
then both sides in the same PR.
Period 1: before writing I7
Status: handled on 2026-10-25 (commits 396e6f4 and the one that
follows it). The decisions are recorded in installer/CLAUDE.md,
installer/DESIGN.md and the I7 checklist in installer/TASKS.md; the
code fixes are done and tested; the “build into I7” items are requirements
on I7 rather than work that could be finished before it exists, so they are
reproduced in that checklist and ticked here as specified, not as built.
Decisions (recorded in installer/CLAUDE.md)
- P2–P6 Ledger schema: per-file
sha256,prior(backup|identical|absent), case-folded keys with the original spelling kept, andstate: "installing"for write-ahead. Adopted as proposed. - P7 Overlapping files: last writer wins, logged, and reported to
the server. Neither of the two options offered — a user must never
be blocked by a catalogue mistake they cannot fix, and a line in a
log nobody reads does not reach the one person who can fix it. The
installer therefore sends a conflict report (
POST /report/conflict;server/TASKS.mdS10). The known cost is accepted and written down: after uninstalling the winner, the file survives holding the winner’s bytes. - P8 Exclusivity: engine only. A choice category is an implicit
group (scene 5 asks for one pick);
exclusive_groupis superseded inplan(). No gate on scene 4. Two members ticked in one run both install, the later wins — which is exactly the P7 case, and is reported the same way. - P9 Choice categories move to a per-game list in
gamedir.rs, each with a localized label AND explanation. Not the manifest: that text is localized UI, and the server cannot know the installer’s languages. Only choice categories are mandatory on scene 4. Consequence accepted and specified: the manifest needs a version, and an installer older than the catalogue blocks DOWNLOADING only — reconfiguring and uninstalling keep working (server/TASKS.mdS11). - P10 Install root becomes a per-game property alongside the
categories. RBR = the game folder, as today. The other three stay
undecided until that game has packages, and deciding is part of
adding the game; the BeamNG note (mods under
%LOCALAPPDATA%) is recorded there so it is not rediscovered. - P14 Restore: dated restore points, chosen from a list, each pairing the replaced files with the ledger as it was. Restoring also REMOVES what was installed after that point — copying files back alone leaves a broken package’s added files behind, which is the bug the legacy restore had. The scene previews what the restore will do before it does it.
Shared
- D4
Manifest::get(game, &PackageRef), used invalidate_requires; refs resolve within the package’s own game, and a same-named package in another game no longer shadows the real one. - D12
valid_namerejects control characters (below 0x20, and 0x7f).
Installer: fix in the existing code
- D1
has_payload()/needs_download(), with three tests: an update is ticked AND downloaded, a current leaf is not refetched, a local-only leaf is never queued. - D6 Rename straight over
cache.json;versioninIndex;opentells Missing, Unreadable and Loaded apart. The old comment was measured and was wrong —std::fs::renamereplaces on Windows. - D6 Sweep rules:
orphans()returns nothing unless the index was genuinely read, so the rule cannot be forgotten by the I9 caller. “Only orphans older than N days” is recorded in theorphans()doc comment for I9, which is where the age policy belongs. - D7 Named mutex at startup with an “already running” modal. Verified with two live processes. The UI harness refuses to launch while another copy runs, so this never masquerades as a missing scene.
- D12 The cache index stores
exclusive_group,requires,sizeandupdated_at. - D12 Zip parsing fails when the entry count disagrees with the EOCD record.
Installer: build into I7 (specified, not yet built)
Each of these is now a line in installer/TASKS.md I7 with its reasoning.
- P1 Ledger merged into the tree as an “installed” state, ticked by default; ledgered packages missing from server and cache shown as “installed (unavailable)”.
- P1 Confirm the removal list before phase 2.
- P2 Uninstall deletes a file only when its hash still matches.
- P3
prior: identicalrestores from the cached payload; those payloads are pinned against the sweep. - P4 Uninstall handles each file by its
prior. - P5 Case-folded ledger keys;
safe_relativerejects trailing dots and spaces and reserved device names; duplicate zip entries removed. - P6 Pre-flight before any uninstall (CRC, free space, writability, game not running — P12).
- P6 Write-ahead
installingstate with recovery. - P6 Temp name then rename, per file.
- P7 The overlap rule above, plus the report.
- Table tests for all of it, listed in the I7 checklist.
CI
- O2 Checked: both remotes’ HEAD is
master, soci.ymltriggers correctly. Themainin the session header was a tool’s guess, not the repository’s default branch. No change needed.
Documentation, fix now
-
installer/tests/tree.rs: the comment that contradicted its assertion is gone; the test now asserts the download (D1). -
installer/src/cache.rs: the rename comment (D6). -
installer/src/api.rs: the.partcomment (D7). -
installer/CLAUDE.md: the cache index fields — the code now stores what the document claimed (D12). -
installer/DESIGN.md: the restore scene, rewritten around restore points (P14). -
server/TASKS.md: stale sidecar categories. -
server/CLAUDE.md: typo “invariantsr”.
Period 2: before the first public release
Status on 2026-10-27. The SERVER side of this period is finished, and
so is the protocol except for generated_at/generation, which Period 3’s
/ready wants anyway. The restore scene (P14) closed the last functional
gap in the installer.
What is left is a handful of small installer items and the whole of deploy and CI, which nobody has started. Both lists moved out of this document — see “Installer, deploy and CI” below.
Protocol
- S1 Add
schema,generated_at,generationandmin_installertoManifest. The installer asks for an update when it is too old. Superseded in part on 2026-10-25: the maintainer chose a per-game install schema checked by raw-file SHA instead of a manifest version (server/TASKS.mdS11; server side done).generated_at/generationremain open for Period 3’s/ready. - D2
GET /whoami(or a response header) returning{ valid, expires_at }. Scene 2 uses it, and warns when a key is about to expire. Both halves done: server 2026-10-25 (/whoami: 200{expires_at}/ 401), installer 2026-10-25 — scene 2 validates there now, and a key within 14 days of expiry says so with the date. - C2
Cache-Control: private, no-storeandVary: Authorizationon/packages;Cache-Control: privateon protected downloads;ETag: "<sha256>"on public ones.
Server
- D3 Return 404 for a non-public directory to any caller without a
grant for it (or 401 whether or not the package exists). Update the
matrix in
server/tests/api.rs. - D8 Fail closed when a token row cannot be decoded.
- D9 Skip the token lookup for public packages, and fall back to the public listing on a DB error.
- D9 Reject tokens that are not
pcn_plus 43 characters before querying (also C4). - D10 Handle SIGTERM, and bound the drain to about 20 s. Stopgap:
StopSignal=SIGINTin the Quadlet. - D11 One INFO line per response: method, path, status, bytes, duration, token-hash prefix, user agent.
- D12
Content-Dispositionnames the leaf, with RFC 6266filename*. - S4 Minimal fix: return 503 and log an error when the file size differs from the manifest size.
- S7
journal_mode=WALandbusy_timeout.
Publishing (manifest gen)
- D5 The manifest becomes a pure function of zips plus sidecars, with
versionrequired or derived. - D5 A one-off
manifest migrate-to-sidecarscommand for the values that are only in the current manifest. - S5 Write
manifest.jsonatomically (temp file, then rename). - S5 A broken sidecar skips its package and makes the run exit non-zero. The entrypoint reports the failure visibly instead of only echoing it.
Installer, deploy and CI — moved to the task lists
These were a second copy of a todo list, kept in step by hand, and on 2026-10-27 that drifted far enough to be worth ending. The items now live where work is picked up; the FINDINGS they came from stay above, with the reasoning and the severity:
- installer items (C1, S1’s user agent, the two D12s, P11, the §7 log
header, retries and DPAPI) →
installer/TASKS.md, “Pre-release hardening”; - deploy, CI and supply chain (O1, O2, C3, S9, and the two
deploy/README.mdnotes) →TASKS.md, M7.
Done here since the review was written, for the record:
- D12 The catalogue error is written into the run log.
- S1 The user agent is
Pacenotes-Installer/<version>, taken from the build, so the server’s access log (D11) names the build behind every request. - D12 Scene 4 asks for the catalogue even when
/healthfailed;/healthdecides only scene 2’s status line. - D12
http::getrefuses a body over 8 MiB, counted as it arrives. - P11 Scene 3 probes the folder when a route is taken and, when this account cannot write it, offers to restart as administrator, resuming on scene 3 with the language, folder and key.
- §7 A transient download failure is retried; one that lasts leaves that package out, the rest carry on, and scene 7 names it.
- P12 The pre-flight refuses while the game runs from this folder, found by the process’s own path.
- §7 Storing the secret: decided NOT to store it at all, so there is nothing for DPAPI to protect. It is typed each run; only a restart as administrator carries it (P11).
- §7 The run log opens with the build, the start time, the server and the Windows build, and times every line in UTC — in the shape the server’s log uses, so the two can be laid side by side.
- P14 The restore scene, with two rules the design had not named: a package whose payload is gone is left installed rather than removed, and the ledger afterwards keeps the snapshot’s per-file provenance.
- §7 One log file per run, in the game folder, newest five kept — so re-opening the installer no longer erases the failed run.
- A panic leaves a report naming its file and line. Not in the review: added after a crash left nothing at all.
- CLAUDE.md: the “status never reveals what exists” claim (D3) — now true, and the text describes how.
- installer/DESIGN.md: how the secret is
validated (D2) — scene 2 documents
/whoami, why it is not/packages, and what “could not ask” does. - server/CLAUDE.md:
Content-Disposition(D12). - deploy/README.md: what is reproducible (D5, S9).
Period 3: before more than one server
Storage and publishing
- S4 Content-addressed blobs (
data/<game>/blobs/<sha256>.zip), with the folder tree kept as the authoring view. - §5 Publishing as a job:
manifest genruns once and uploads the blobs and the manifest with itsgenerationid. - §5 Instances load the manifest from storage on a timer or on
SIGHUP, and swap an
Arc: no clone and nostatper request. - §5 A server-side
PackageSource(local filesystem, object store, redirect). - §5 Serve the MOTD from the same storage as the manifest.
- S5 Reuse the previous sha when path, size and mtime are unchanged.
- S5
manifest gen --check(a dry run with a diff) and--strict(fail on warnings).
Downloads
- §5 Public blobs from a CDN with immutable cache headers. Protected blobs authorized by the server, then redirected to a short-lived signed URL. This needs C1 to have shipped.
- C4 Per-IP rate limits on
/download.
Auth and database
- §5 Turso, or embedded replicas (re-adding libsql’s
replicationbecomes affordable once O1 builds in CI). - S7 Track the migration version (
user_versionorschema_migrations). The CLI migrates, the server only checks. - S7 Keep a
Databasehandle and open a connection per request. Check that remote streams can be reused under concurrency. - S7
PRAGMA foreign_keys = ON.
Operations
- S6
/ready: manifest loaded, package count, generation, DB reachable. Wire it toHealthOnFailure=or the orchestrator’s probes. - C4 An option for structured JSON logs.
Security
- S2 An ed25519-signed manifest, with the public key embedded in the installer. Measure the verifier against the 500 kB budget first.
Backlog (low priority, no deadline)
- C2 Range requests and
Accept-Ranges, with resume in the installer. - C3 A fallback hostname.
- P13 An optional sidecar key
replaces = [...]that carries ledger and cache entries across a rename. - P15 Seek per entry instead of reading the whole zip.
- S3 A per-level label map, for localized tree labels.
- S8
last_used_atand a per-key download count intoken list, and atoken rotatecommand. - O2 Avoid the
:Zrelabel cost on a large data tree. - §7 Reuse one WinHTTP session for a whole run.
Changelog
2026/11/02 — deploy: the server image builds again (M7)
The v/2.0.1 release failed in “Build and push the server image”: cargo build -p pacenotes-server exited 101 inside the container. The docs commit
had added tools/mdbook-repo-links to the Cargo workspace, and
deploy/Containerfile copies only the paths it names into the build. Cargo
reads every workspace member’s manifest before building any one package,
so the missing tools/ stopped it, though the server does not use it.
The Containerfile now copies tools/. Reproduced first, by loading the
workspace from exactly the paths the old Containerfile copied (the same
“failed to load manifest for workspace member” error), then a release build
of the server from the new file set.
Nothing before a release built from just those paths, so every CI check had
passed. The server job now does: it copies what the Containerfile’s COPY
lines name into an empty directory and loads the workspace there, which
fails on the v/2.0.1 Containerfile and passes on this one. The root
CLAUDE.md says a new workspace member needs its COPY line.
2026/11/02 — README: matches the application as it is (M7)
The README had drifted from the code. It said downloads went to a per-user
cache (they live in the game folder, as its own later section said), that
scene 3 offered two buttons (there are three: Restore/Uninstall was
missing), described wizard/ as having two routes and the wizard as seven
scenes, listed legacy/ as part of the repository (it is git-ignored, and
not one file of it is tracked), and pointed at the Python-drawn
architecture README. It also opened with an unclosed HTML paragraph, which
mdBook warned about.
Rewritten against the code: the three routes and what gates them, the download page’s expanded tree and partial boxes, retries and what happens when a category or the whole transfer comes back empty, the restart as administrator, the refusal while the game runs, restore points, and the documentation site. The layout table names what is really there.
2026/11/02 — docs: one mdBook on GitHub Pages, with rustdoc and D2 diagrams (M7)
The documentation was a dozen Markdown files spread over the repository,
plus nine architecture diagrams drawn as PNGs by Python and Pillow from
explicit coordinates. It is now one site, built by docs/build.sh and
published to GitHub Pages by .github/workflows/docs.yml from master:
- Every document, included rather than copied: each chapter under
docs/src/is one include line at the same relative path as the original, so a document keeps one source and its links work in the book as on GitHub. mdBook’s defaultindexstep is off, because it renames README chapters and kills every link to them. - The diagrams, redrawn in D2 and rendered to SVG at build time: the two ER models, four activity diagrams, and the SysML block, deployment and requirement diagrams, corrected to the application as it is (the 700 kB gate, the scene 5 readiness check, identical files no longer rewritten, the running-game check, redirects). The SVGs are never committed. The Python generator and its PNGs are gone; there is no Python left in the project.
- The API reference, rustdoc with private items for the shared crate, the server, and the installer’s library and Windows application. The installer’s two halves share a crate name, so each gets its own run; it builds only on Windows, so CI makes it in a Windows job and hands it over.
tools/mdbook-repo-links, a small Rust preprocessor in the workspace: a link to a repository file that is not a chapter (source, scripts, the licence) points at the file on GitHub instead of dying in the book.
One thing had to change in the installer for this: rustdoc prints the
default server address build.rs reads from installer/.env, so a docs
build on a machine with a real .env would have published that address.
build.rs now reads the file PACENOTES_ENV_FILE names when it is set,
and the docs build always points it at .env.example.
deploy/README.md no longer pipes through python3 -m json.tool, and the
unused authors key left [workspace.package], where Cargo warned about it
on every build.
Pinned to the latest releases, D2 v0.9.0 (now at d2lang/d2) and mdBook
v0.5.4. Rendered with D2 and inspected, the first drafts needed work:
- Light only. With a dark variant, a system in dark mode switched the text to light colours while the boxes kept their light fills, unreadable. An image in a page follows the system’s scheme, not the book’s theme.
- Each source names its own layout in its
d2-configvars: dagre for the activity flows, which ELK tangled into back-edges; ELK for the tables and containers.build.shpasses no flags. - Width. D2 does not wrap labels, so long sentences made the ER diagrams 5 900 px wide. Labels are broken by hand, column types are short (the chapter’s tables explain them), and containers stack; nothing is wider than 2 300 px now, most under 1 600.
- SY-3 is a grid, one row per requirement — test case, requirement, block — so no line crosses another, and SY-1’s composition edges carry their diamonds.
2026/11/02 — deploy: the forced command’s path is generated, not typed
The first real deploy failed with exit code 127, fish: Unknown command: /home/<user>/.local/bin/pacenotes-update. Not fish and not a hang: sshd runs
an authorized_keys command= through the user’s login shell, and that path
did not exist — the server user’s home is /srv, and the README’s template
said /home/<user>. The README now generates the line from $HOME (under
bash -c, since the login shell may be fish) and says what a wrong path
looks like. Also fixed: a doubled ~/.local/.local/bin in the install step
and a stale ~/bin in the Quadlet’s header.
2026/11/01 — ci: actions on Node 24, and where the default server lives
GitHub now forces Node-20 actions onto Node 24 with a deprecation warning.
The runtime is chosen by each action’s own runs.using, not by the runner
image, so the fix is newer action versions, checked one by one against
their action.yml: checkout v5, upload-artifact v6, setup-buildx v4,
login v4, build-push v7 and action-gh-release v3 all declare node24, and
every input these workflows pass still exists. rust-cache v2 already runs
on Node 24; rust-toolchain is a composite action with no Node at all.
The release job failed with PACENOTES_DEFAULT_SERVER unset although it
was set — in the production environment, whose variables only reach jobs
that name it. The installer job deliberately does not: naming it would
expose the deploy key to a job running third-party actions and crates’
build scripts, and put installer releases behind any reviewer rule meant
for deploys. The variable belongs at repository level; the error message
and deploy/README.md now say so, and why.
2026/11/01 — ci: the workflow runs again, and checks itself
ci.yml had been invalid since 3f516e0: the installer job’s name,
installer (windows: lint, …), held a colon followed by a space, which YAML
reads as a nested key, so GitHub refused the whole file and none of CI ran.
The name is quoted now.
Also:
- A
workflowsjob runs actionlint (pinned to v1.7.7, with the runner’s ShellCheck) over every workflow file, so a file GitHub cannot parse — or a broken expression,needs:target or shell line — fails a run instead of silently never starting one. timeout-minuteson every job (5 / 20 / 45) instead of GitHub’s 6 hours, which a hungTest-HttpClient.ps1could otherwise hold a runner for.- The Windows job caches the repository root’s
target/as well as the installer’s:Test-HttpClient.ps1buildspacenotes-serverthere, and it was rebuilt from scratch every run. That build is now--locked, like the server job’s. permissions: contents: readfor the workflow’s token, and aconcurrencygroup that cancels a branch’s or pull request’s older run when a newer push arrives.cd-release-on-tag.yml: the server job’s outputs are written in one grouped redirect (ShellCheck SC2129, which the new lint would flag).
None of this could be run here: actionlint is fetched from GitHub at run time, and downloading it locally was not permitted. The next push is the test.
2026/11/01 — release: the server image is built in CI and deployed on a tag (M7)
Pushing a v/X.Y.Z tag now runs three jobs in cd-release-on-tag.yml: the
installer to GitHub Releases as before, the server image to
ghcr.io/<owner>/pacenotes-server:X.Y.Z (and :sha-<12>), and, once the
image exists, a deploy to the VPS. The repository is public, so its workflow
artifacts are too; the server therefore ships ONLY as a GHCR package, which
is private, and never as a release asset or artifact.
The host stops building. The Quadlet runs the local tag
localhost/pacenotes-server:current with Pull=never, so a restart is just
a restart. The new deploy/pacenotes-update is the only thing that moves
that tag: CI SSHes in with a key that authorized_keys locks to the script,
which takes nothing from the caller but an X.Y.Z version, pulls that image
with the host’s own read-only token, keeps the running one as :previous,
restarts and waits for the healthcheck — and puts :previous back and fails
the job if the new container never turns healthy. podman auto-update was
not used: it cannot be scoped to one unit, and nine other containers on the
host carry AutoUpdate, so every release would have updated them too.
Tested against isolated storage with stand-in images and a throwaway unit:
a healthy update, a redeploy of the same version (a no-op), and an update
whose healthcheck fails (rolled back, exit 1). The first run found a real
bug: podman untag <name> with one argument strips EVERY name from the
image, so a successful deploy dropped :current and the following prune
could delete the running image; it now names the image and the tag.
Publishing content is now manifest gen alone; it never deploys code.
deploy/README.md covers the GHCR token, the deploy key, the production
environment’s five secrets and the move from the build-on-start unit. The
GitHub run, the GHCR package’s visibility and the SSH hop cannot be tested
from here: the first real tag is the end-to-end test.
2026/11/01 — installer: a larger window, and the Download tree opens expanded (I9)
The window was 615x512 px, sized for an 800x600 screen. Every page is now 470x300 DLU instead of 330x215 — about 860x680 px at 100%, the most that fits a 1024x768 screen above its taskbar. Each page keeps its arrangement; the extra width widens every control, and the extra height goes to what holds lists: the Download tree, the logs, the message of the day, the configuration lists and the restore list and preview.
The Download tree opens with every row expanded, so each package is in view without a click. It had opened collapsed so every category needing a pick stayed on screen; a long co-driver list now pushes the categories below it a scroll away, which the larger window offsets.
Test-UiLayout.ps1 checks the window is larger than 800x600 yet fits
1024x768 above a taskbar, measured at 100% from the window’s own DPI;
Test-WizardDownload.ps1 checks every row is shown without a click.
2026/11/01 — installer: a partly ticked category shows a partial box (I9)
On scene 4, a category with only some of its items ticked showed a blank
space where its checkbox belonged. The model and the control both knew the
state (image 3), but the control builds its checkbox images when
TVS_CHECKBOXES arrives, and from the template that was before the partial
style was asked for: there was no third image to draw. The template no
longer sets TVS_CHECKBOXES; the page adds it after the partial style.
With a real partial image the control cycles unchecked → checked → partial, so a click on a partly ticked category now ticks all of it, rather than clearing it as the cycle’s next step would.
Test-WizardDownload.ps1 ticks one voice and measures the category’s
checkbox as drawn: the old build drew 1 dark pixel there against 59 for an
empty box. The tree helpers now find a row by walking the visible rows, so
they work on an expanded tree too.
2026/11/01 — installer: installs four to fifteen times faster (I9)
Measured on a voice-pack-shaped package, 3 000 files and 114 MB, on one machine:
| before | after | |
|---|---|---|
| fresh install | 11.8 s | 3.0 s |
| install over a manual install of the same files | 20.3 s | 1.35 s |
| uninstalling that again | 3.1 s or worse | 0.63 s |
- The zip’s directory is read once per package, not once per file
(
zip::Archive). Finding each file by walking the whole directory made an install of n files n² entry parses: 8.5 s of the 11.8. - A file that already holds the incoming bytes is not written again, nor decompressed a second time: its hash is taken during the comparison that found it identical. On a first run over a manual install that is every file.
- Uninstalling no longer re-reads the payload for every file. An identical original of an installed entry needs nothing at all — the file was just hashed against what was written, which is the original — and a write-ahead entry reads the payload once.
- The CRC check uses a full byte table (1 KiB) instead of a nibble table: 0.42 s → 0.24 s.
- The log pane appends a line instead of setting its whole text again for each one. On the control itself, 1 500 lines took 19.8 s that way and 3.5 s appended. It mattered most where there are many lines — a first run backing up the files of a manual install — and it ran on the UI thread, so the finished run waited behind it.
What remains, and why it stays: writing through a temp file and a rename
(1.8 s against 1.1 s for plain writes) keeps a half-written file from ever
existing under the real name, and the SHA-256 of each file (0.43 s) is what
the ledger records. New tests: an identical file keeps its modification
time, its original survives an emptied cache, Archive reads what
read_entry does, and scene 6’s pane holds the run log in order.
Size: 627.5 kB, +5 kB.
2026/11/01 — installer: the way forward is light green (I9)
The button that carries the normal flow forward now has a light green background: Next in each of its labels, Finish, and scene 3’s Download/Update. A disabled one stays the theme’s grey, and Back, Cancel and the secondary routes keep their look.
No library changes. A themed push button ignores the colours its parent offers, so these are painted whole from the button’s custom-draw notification: fill, border, label in the button’s own font, and the hot, pressed and focused states. Next and Finish belong to the property sheet, so the sheet is subclassed once it exists; Download/Update is painted by scene 3’s page.
Test-UiLayout.ps1 samples the rendered colour of the primary button,
Cancel, both kinds of route button and the disabled Next (70/70); on the
previous build both “light green” checks fail on the theme’s grey. The
drawing pulls in gdi32.dll, which Windows ships, so Test-BinarySize.ps1
allows it — and its import-gate message, whose -f formatted only half
the string, now names the DLL it complains about.
Size: 622.5 kB, +1.5 kB.
2026/11/01 — server: no colour codes in a log that is not a terminal (S12)
The server coloured its log whether or not anyone could see the colour, so
a log written to a file or the journal kept the ANSI escape codes, and a
search for agent= missed every access line: the field name and its =
sat on either side of an escape. Test-HttpClient.ps1 failed that way
when run from a terminal that set TERM. Colour is now on only when
stdout is a terminal.
2026/11/01 — installer: the message of the day keeps its line breaks (I9)
Scene 2 showed the message of the day as one run-on line. A Win32 edit
control breaks lines only at CRLF, and a .txt written on the server, or
in any editor set to LF, ends its lines in LF alone. The text is now
re-joined with CRLF before it is shown, whichever endings the file used.
Test-WizardDownload.ps1 serves a three-line, LF-only message and reads
back the line count the control itself reports (140/140 in both
languages; the unfixed build showed 2 lines, the one long line wrapped).
2026/11/01 — installer: the size gate moves to 700 kB
The scene 5 readiness check landed the release build on exactly 620 kB
(634,880 bytes), with nothing left for the next change. The maintainer
raised the gate to 700 kB. Unlike the two raises before it, this one was
not measured against the levers in installer/CLAUDE.md: the icon and
serde_json are still unspent, and the forbidden crates stay forbidden.
2026/11/01 — installer: scene 5 opens only with something to pick (I9)
Download/Update → Back → Configure/Install → Back → Download/Update, on a folder with nothing downloaded, let Download through with nothing ticked into an empty configuration screen; doing it again locked scene 4. The Configure route rebuilds the shared tree from the cache alone, and scene 4, having loaded its catalogue once, never loaded it again: the page showed the catalogue while its gate read the cache-only model, and every tick pointed at a row the model no longer had. Taking that route now marks the catalogue as not loaded, so scene 4’s next visit fetches and rebuilds both.
The rule the report asked for holds on its own: the configuration screen opens only when every one of its questions has a ticked package whose payload is in the cache. Configure/Install checks it on scene 3 and stays there with a warning naming what is missing; Download checks it after 4b, and when every voice failed, say, returns to scene 4 with those unticked. This reverses an earlier choice: a category the server publishes nothing under used to leave an empty list and install without it, and now holds the wizard short of scene 5.
Returning to scene 4 exposed a second bug: 4b decided what to fetch from tree states dating from scene 4’s first visit, so a second transfer fetched everything again. It now skips what the cache already holds at the catalogue’s sha256.
Test-WizardDownload.ps1 repeats the reported sequence twice on a fresh
folder before its download gate, and an eighth pass fails every voice
(138/138).
Size: 620 kB, +2 kB — exactly at the gate.
2026/10/31 — installer: it will not install while the game is running (P12)
A running game holds its files open, so an install into its folder failed
partway with sharing violations. The engine’s pre-flight has refused when
told the game was running since I7 — but nothing ever told it: the wizard
passed false.
The installer now looks for the game’s executable among running processes, and compares each one’s FULL PATH with this folder. A copy of the game running from another install does not lock this folder, so it does not block; one whose path cannot be read (running elevated, say) counts as running, since refusing is the safe answer. The check guards installs and restores alike, before anything is written.
Test-WizardDownload.ps1 gained a seventh pass, with Windows’ own
ping.exe standing in for the game under its marker’s name: running from
another folder, the install goes ahead; running from this one, it stops
with “the game is running”, the ledger untouched (118/118).
Size: 618 kB, +0.5 kB. std::fs::canonicalize would have cost 3.5 kB and
broken the gate; the two path variations that matter are normalised by hand.
2026/10/31 — installer: no Visual C++ redistributable needed; the gate moves to 620 kB (I10)
The exe imported VCRUNTIME140.dll, the Visual C++ redistributable, which
Windows does not ship. On a PC without it, Windows refused to start the
installer with an unlocalized “VCRUNTIME140.dll was not found” — before a
single line of ours could explain anything. The README told users to go
and install it.
Measured on the same commit:
| Build | Size | Needs the redistributable |
|---|---|---|
| dynamic CRT (as it was) | 595.5 kB | yes |
| fully static CRT | 692.5 kB | no |
| hybrid CRT: VC runtime linked in, Universal CRT from Windows | 617.5 kB | no |
The installer now builds with the hybrid CRT. The Universal CRT has shipped
with Windows since Windows 10, so the exe needs nothing installed. The icon
lever would have fitted that under the old 600 kB gate; the maintainer
chose to keep the icon and raise the gate to 620 kB, its second raise,
argued in installer/CLAUDE.md “The size budget”.
+crt-staticlives ininstaller/.cargo/config.toml, andbuild/runtime_link.rsadds the two link arguments that keep the Universal CRT dynamic. ARUSTFLAGSvariable silently REPLACES the config file’s flags, sobuild.rsrefuses to build without+crt-static, and CI andRun-AllTests.ps1now deny warnings through clippy rather thanRUSTFLAGS(CI keepsRUSTFLAGSfor the server job only).Test-BinarySize.ps1reads the exe’s import table and fails if it needs any DLL Windows does not ship —VCRUNTIME140.dllcoming back, or any new one. It parses the PE file itself, so it needs no Visual Studio tools.- The README no longer sends users to install the redistributable.
2026/10/31 — installer: Enter and Tab on scene 3 (I9)
Measured with real keystrokes on every scene that runs without a server. Scenes 1 and 2 were already right: focus starts on the one input, Enter presses the primary button, Esc asks before closing. Scene 3 was not:
- Enter did nothing. The page draws Download/Update as its default, but in a property sheet Enter goes to the sheet’s own Next, which scene 3 disables, since its routes are buttons in the page. The folder field now claims Enter and takes Download/Update, with the same checks as a click — no game found, or a folder this account cannot write, say so.
- Tab reached the main route last, after both secondary ones, because template order is Tab order. Download/Update now comes straight after the folder and game; nothing moved on screen.
Esc needed nothing: it asks first wherever Cancel is offered, and the dialog manager ignores it on scenes 6 and 7, where Cancel is not.
Test-UiLayout.ps1 now presses keys through the dialog manager (Send-Key,
Get-FocusedControlId in Win32Ui.psm1), posted into the wizard’s queue
rather than sent as real input: real keys go to whatever window is in front,
and a background test could not reliably put the wizard there. It checks
where focus starts on scenes 1–3, scene 3’s Tab order and its Enter, in both
languages (65/65). Size: 595.5 kB.
2026/10/31 — decided: the last game folder is not remembered (I9)
Remembering the last folder per game would need storage in the user
profile, outside the game folder — the exception already declined for the
access key. Scene 3 starts empty each run, except after a restart as
administrator, which carries the folder over. The item leaves
installer/TASKS.md; the decision is in installer/requirements.md. No code
changed.
2026/10/31 — installer: the cache sweeps what nothing needs (I9)
I9 asked to sweep “orphaned” payloads — the ones the cache index no longer names. Built as written it would have broken restore: the index keeps one version per package, so after an update the previous version’s payload is unnamed, and that is exactly the payload a rollback needs.
Since the cache moved into the game folder, everything that can need a payload sits in the same folder, so the sweep can know what is in use:
- When a route is taken on scene 3, the cache removes leftover
.partfiles and every payload named by none of the index, the ledger and the restore points. The run log says what went and how much it freed. - If the ledger or any restore point cannot be read — written by a newer build, or damaged — no payload is deleted: what it needs is unknown.
- A
--cacheoverride is never swept; it may serve other folders. - No “remove from cache” control: what nothing needs goes by itself, and what something needs must not.
Tests: two unit tests for the sweep; three scenarios on a real game folder
(the payload a rollback needs survives and the rollback still works, a
version nothing ever installed goes, and an unreadable ledger stops the
sweep); and the flow test checks a leftover .part file is removed by a
real run and logged (115/115).
Size: the first version cost 11.5 kB and broke the gate (603 kB) — two
BTreeSets and a second copy of the restore-point reader. Rewritten over
plain slices and the existing restore::points, it costs 3.5 kB: 595 kB,
5 kB left under the gate.
2026/10/31 — installer: no unwrap, expect or panic outside tests, enforced (I9)
I9 asked to replace the unwraps left in UI paths. A sweep found none left:
every unwrap, expect and panic! in the installer’s own code was in a
test module, in --selftest-panic (whose whole job is to panic), or in the
build script, which is meant to fail the build with a message.
So the item became a rule that stays true: lib.rs and main.rs deny
clippy::unwrap_used, clippy::expect_used and clippy::panic whenever
they are not compiled for tests, and CI’s clippy runs with -D warnings.
The deliberate panic carries an allow saying why. The root and installer
CLAUDE.md drop “unwrap tolerated while scaffolding” for the rule as it
now is. Behaviour and size are unchanged.
2026/10/31 — decided: the access key is never remembered (I8, §7)
Two open items planned to remember the access key and to encrypt it with DPAPI. The maintainer decided against both: the key is typed on scene 2 each run and never stored. Scene 2 runs before a game folder is chosen, so a remembered key could only live in the user profile — the one exception the “nothing outside the game folder” rule would have needed — and a key left behind on a shared or borrowed PC is worse than retyping it. The only time the key travels is a restart as administrator (P11).
The items are gone from installer/TASKS.md; the decision is recorded in
installer/requirements.md under “Things that are deliberately NOT done”
and in installer/DESIGN.md, scene 2. No code changed.
2026/10/31 — installer: a failed download is retried, and no longer ends the run (§7)
One failed package ended the whole run at scene 7, whatever the reason and however many others had arrived — a server that blinked once cost the user everything they had ticked.
- A failure a retry could fix is retried: no response at all, 408, 429, 5xx, or a payload damaged in transit (its sha256 did not match). Three attempts, waiting 1 s and then 3 s; Cancel works during the wait, and each retry is a line in the run log. A 401 or 404 fails at once, and so does a local disk error, which is not the network’s fault.
- A package that still fails is left out and the rest carry on: scene 5 does not offer it, and scene 7 names it with the log open. A failed UPDATE keeps the installed version — leaving it out would make the plan uninstall it, which a failed download must never do.
- When nothing at all could be downloaded the run still ends at scene 7.
- Scene 4b and
--selftest-downloadshare one retrying path, which now reportsRETRIES:.
Test-HttpClient.ps1 drives the selftest through a stand-in in front of the
real server: a 503 once and a damaged payload once each arrive after exactly
one retry, and a 404 fails after one request (60/60). Test-WizardDownload.ps1
gained a sixth pass where one package fails once and another for good: the
run reaches scene 5 without the lost voice, installs the rest, and scene 7
names it (112/112).
Size: 591.5 kB, up 3 kB. 8.5 kB remain under the gate.
2026/10/31 — installer: a folder it cannot write is caught on scene 3 (P11)
A game under C:\Program Files cannot be written by a standard account,
and the installer asks for no elevation (asInvoker). Since the download
cache moved into the game folder, such a run got through scenes 3, 4 and
the picks, then failed at scene 4b’s downloads.
Now scene 3 probes the folder when a route button is pressed — the moment the user commits to it. The probe removes whatever it created, so a folder the user abandons keeps no trace. When it cannot write:
- it names the folder, says that is usual under Program Files, and offers to restart as administrator. No, or cancelling the UAC prompt, leaves the user on scene 3;
- the elevated instance opens straight on scene 3 with the language, the folder and the access key carried over. The key is on that process’s command line, readable by other programs running as this user: the maintainer’s call, for a restart that asks nothing twice;
- it waits for the instance that launched it to close. That needed a fix in the single-instance check, which kept the handle it got for a name already taken — and so kept the mutex alive after its owner had gone;
- already elevated and still unable to write, it explains and offers nothing, since asking again would loop.
Tested where it happens: Test-UiLayout.ps1 makes a folder this account
cannot write (a deny rule, no admin needed), checks scene 3 stops with the
.rc‘s own message and leaves the folder untouched, then launches with the
restart arguments while the first instance is still open and checks it
resumes on scene 3 in that language, with the folder and its game (60/60 in
each language). The command line round-trips through Windows’ own
CommandLineToArgvW in unit tests, folders with spaces and a trailing
backslash included. The UAC prompt itself is not automated.
Size: 588.5 kB, up 6 kB — the most of today’s changes, for the token query, the UAC launch and the restart’s command line. 11.5 kB remain under the gate.
2026/10/31 — installer: a cap on what http::get holds in memory (D12)
http::get — the manifest, the MOTD, /health, /whoami — read the
whole body into memory with no limit. The documents are small, but that is
the server’s promise rather than the installer’s, and a broken or hostile
server sending without end would have eaten the machine.
It now refuses a body over 8 MiB, far beyond any real manifest (kilobytes;
8 MiB would hold some 20,000 packages), and says so: “the response is
larger than the 8.0 MB accepted from …”, not a bare abort. It COUNTS as the
body arrives rather than trusting Content-Length, which a chunked reply
does not send. Packages are unaffected: they stream to a file through
get_streaming.
Test-HttpClient.ps1 checks both sides of the line with a loopback
listener — the real server never sends such a body: exactly 8 MiB is
accepted and read in full, an endless chunked one is refused naming the
cap (54/54).
2026/10/31 — installer: a failed /health no longer hides the catalogue (D12)
One failed GET /health on scene 2 made the whole run offline: scene 4
built its tree from the cache without asking for the catalogue, although
/packages would have answered — a load balancer hiccup, a /health
slower than the rest. It did the same when the user simply reached scene 4
before /health had answered at all.
Scene 4 now always asks for the catalogue; /health decides only scene 2’s
status line. When both fail the status still says the server could not be
reached, when only the catalogue fails it says the list could not be read,
and a catalogue that answers after /health did not is written to the run
log, since that mismatch is what support would want to see.
Test-WizardDownload.ps1 gained a fifth pass that builds exactly that
situation: a stand-in in front of the real server fails /health and relays
everything else. Scene 2 reports Offline, scene 4 shows the whole catalogue
and says it is ready (103/103).
2026/10/31 — installer: the run log says when, which build and where (§7)
Support asks the same three things before anything else: when did this happen, which build was it, and which server was it talking to. The run log answered none of them.
- Each log FILE now opens with a header —
Pacenotes Installer <version>(the release tag’s), the UTC start time, the server URL and the Windows build — then a blank line. - Every line in the file carries its time, in UTC and in the shape the
server’s log writes (
2026-10-31T12:11:31Z …), so the two can be lined up. A line said before the game folder was known keeps the time it was said when it is replayed into the file; the crash hook’s line is timed too. - The Windows build comes from
RtlGetVersion.GetVersionExWwould have said Windows 8: the manifest names no newer Windows, so it is told the compatibility answer. - The pane on scenes 6 and 7 is unchanged: no header, no times.
- Dates are formatted in one place,
utc_time.rs, which the restore point names and the key-expiry notice now use as well.
Test-WizardDownload.ps1 checks the header and the times on the real file
the wizard wrote (98/98). 582 kB against the gate.
2026/10/31 — installer: the release tag is the version
A release used to start by typing the same version into five places —
installer/Cargo.toml and four VERSIONINFO fields in res/installer.rc —
which the workflows then read to CREATE a tag. It now runs the other way:
pushing v/X.Y.Z is the release, and that tag is the only place a
version is written.
cd-release-on-tag.ymlbuilds on a pushedv/*tag and passes it to the build asPACENOTES_RELEASE_TAG.build/release_version.rsturns it into the user agent, the conflict report’s version and all four VERSIONINFO fields (through a header the.rcincludes), and FAILS THE BUILD on any other shape — a mistyped tag cannot become a release. The workflow then checks the built.execarries the tag’s version before publishing.- Without a tag a build is
0.0.0-dev, so a developer’s build is obvious in the server’s access log and in the file properties. - The release on merge to
masterand the manual release are gone: both readCargo.tomland created the tag from it. Merging publishes nothing. Cargo.tomlsaysversion = "0.0.0", with a comment that the tag decides.Test-VersionSync.ps1, which checked that the five hand-typed copies agreed, is replaced byTest-ReleaseVersion.ps1, which builds from real tags and reads the version back out of the binary — file properties and user agent — and checks that nine malformed tags (v1.2.3,v/1.2,v/1.2.3-rc1,v/65536.0.0, an empty one, …) each fail the build.
This supersedes the S1 entry below in one respect: the user agent’s version
now comes from the tag, not from Cargo.toml.
2026/10/31 — installer: the user agent names the build (S1)
The installer announced itself as Pacenotes-Installer/0.1 whatever its
version, so the server’s access log could not tell one build from another.
It is now Pacenotes-Installer/<version>, taken from Cargo.toml at
compile time, so it moves with the version bump that tags a release.
Test-HttpClient.ps1 checks it where it matters: in the real server’s
access log, against the version in Cargo.toml.
That closes the installer’s share of S1. The rest of the finding was
settled elsewhere: the manifest version and minimum installer were
superseded by the per-game schema hash (S11), which already tells an
out-of-date installer to update; the server has logged the agent since D11;
and generated_at / generation wait for the server’s /ready (S6).
The conflict report already carried the same version, so the two agree.
2026/10/30 — installer: a bearer token no longer leaks across a redirect
The design review suspected it (C1, “probably with the Authorization
header”). It is now measured, and it was true: left to itself WinHTTP
follows a 3xx and re-sends Authorization to the new origin, while
http.rs never sees the hop at all. Two local listeners on two ports, the
first answering 302 to the second: the second received Bearer <token> in
full, and the client reported a clean RESULT: OK with the second’s body.
Nothing logged it, because nothing knew.
That is a real leak, not a theoretical one. A 302 from a misconfigured CDN, a hijacked DNS record, or an operator moving downloads without thinking about the header would hand over the user’s access key to a host they never agreed to talk to.
So the decision is taken away from WinHTTP. send now sets
WINHTTP_OPTION_REDIRECT_POLICY_NEVER and follows redirects itself:
- at most three hops, so a redirect loop ends in milliseconds rather than on the timeout;
- no https→http downgrade — refused, not followed;
- the token attached only while scheme, host AND port are unchanged, and dropped the moment any of them is not;
- the 3xx body is never fed to the sink: it belongs to a response the caller is not getting, and would otherwise land inside a package file;
- a request with a body refuses to redirect at all, rather than silently choosing between re-sending the report and turning it into a GET.
A redirect to the same origin still works, so a server that rewrites paths does not break auth, and a redirect to a CDN for public content still works — what cannot happen is the key going with it.
tests/Test-Redirects.ps1 pins it, driving the real WinHTTP path through
--selftest-http: cross-origin drops the token, same-origin keeps it, and
a loop gives up by itself. It needs no desktop and no server, so CI runs
it. The https→http refusal is deliberately NOT covered — an
HttpListener on loopback cannot easily serve real TLS and http.rs
lives in the bin, out of reach of tests/ — and that gap is written down
in installer/requirements.md rather than left to be assumed.
2026/10/27 — installer: nothing is written outside the game folder
The download cache moves from %LOCALAPPDATA%\PacenotesInstaller\cache to
<game dir>\.pacenotes\cache, beside the ledger, the backups, the restore
points and the logs. That was the last thing this installer wrote anywhere
but the folder it was pointed at.
- Deleting the game folder now removes every trace of the tool, and a folder copied to another drive or PC arrives complete — with its downloads and its restore points, which is what makes the way back still work there.
- The cost, accepted deliberately: two game folders no longer share one cache, so a package wanted in both is fetched twice. Disk is cheaper than state nobody remembers to clean up — and the second copy is what keeps the first folder’s restore points working after the second is deleted.
cache::default_rootis gone;cache::dir_for(game_dir)replaces it, and the wizard opens the cache when scene 3 settles a folder, exactly as the run log is attached.--cache <dir>stays as a development override.- An audit of every write the installer makes found nothing else outside
the game folder. What remains elsewhere is deliberate and is not install
state: the
--selftest-*developer flags use a throwaway temp directory, and a panic that happens before any folder is chosen writes its report beside the exe, because at that moment there is no game folder to put it in. - The UI suite no longer passes
--cache, so it exercises the real default, and it asserts both halves: the downloads land in the game folder, and nothing under%LOCALAPPDATA%is touched during a run.
Older versions left downloads in %LOCALAPPDATA%\PacenotesInstaller.
Nothing reads that folder any more; it can be deleted, and the README says
so.
2026/10/27 — installer: the restore scene
The last functional gap. Every install has written a restore point since I7 — the ledger as it was, plus the files that run replaced — and nothing could read one back, so users were accumulating a safety net they could not use. Scene 3’s Restore/Uninstall button now opens a scene that lists those points newest first, previews what going back to one would do, and does it.
- The list is a date plus what the run after it did (
+4 -0 ~0), derived by diffing consecutive snapshots: nothing records what a run was about, and a column of identical dates is not something anyone can choose from. Signs rather than words, so the line needs no Polish plural forms. - The last entry, “Remove everything this installer added”, is a restore to an empty state — the same code, not a second path, and the other half of the button’s name.
- The preview leads with removals, because that is the part to read before agreeing. The PLAN it renders is the value handed to the worker, so what runs cannot differ from what was approved.
- A restore takes a restore point of its own: a user who restores to the wrong state must be able to come back from that too.
- Afterwards the game’s configuration step runs again with the picks the restored state implies, or the files would come back without the language and voice the user had chosen.
Three things that cost more code than the happy path:
- It cannot reuse
engine::plan. That asks the cache INDEX which version it holds, and the index holds one per key: the newest. Rolling back an update means naming the older payload, which is still on disk because payloads are sha-keyed.install_packagenow takes anInstallingdescriptor instead of looking the package up. - Never make it worse. A package whose payload is gone from the cache is not removed either — the wrong version beats an empty folder — and the preview names it before the user commits. Everything else is still restored.
- Restore the provenance, not just the bytes. The snapshot records what
was at each path before this installer first wrote there. A fresh
install of the old version records
prior: absentfor a file it just deleted and rewrote, so a later uninstall would DELETE a file the user always had. The ledger written afterwards takes the snapshot’s entries, and the end-to-end test restores, then uninstalls, and checks the user’s own file comes back.
Found while testing, and fixed: two runs in the same second shared a
restore point directory, the second overwriting the first’s snapshot and
saved files — silently destroying the way back the first run had promised.
Points are now suffixed (…31Z-2) when the name is taken.
Also: rbr.ico was ONE 256x256 frame costing 85 kB of a 600 kB budget,
more than serde and the whole install engine together, which Windows then
downsampled to 16x16 for the title bar. It is now 16/32/48 as BMP plus 128
as PNG, 45 kB, built by installer/res/Build-Icon.ps1 from the source art
— smaller AND sharper at the sizes actually used. build.rs watches the
icon now; it did not, so a new icon was not a reason to rebuild and the old
bytes stayed in the binary.
Tests: 13 planning tests off Windows, 8 against a real game folder (including the bad-update rollback and the provenance proof), and a fourth pass in the wizard suite that restores through the real UI and checks the folder afterwards. 90/90 there, whole suite green, 576.5 kB against the gate.
2026/10/26 — installer: scene 3’s three routes, and a crash that hid itself
- Scene 3’s buttons are now arranged by importance, in two columns:
Download/Update is the main flow, so it is the default button, the widest,
and as tall as the other two together, alone on the right;
Configure/Install and the new Restore/Uninstall are secondary work on
a folder you already have, so they stack in a narrower column on the left,
top and bottom aligned with the big button so the three read as one block.
Test-UiLayout.ps1measures the arrangement — column, stacking order, relative width, matching height — because “looks primary” is the point of it and nothing else would catch a regression.- Restore/Uninstall answers “not implemented yet” until the restore scene lands. It ships early so the page’s shape does not change under users later, and so someone looking for a way back can see one is coming.
- Fixed: Configure/Install → change a pick → Back → Download/Update killed
the installer. The Configure route loads a tree MODEL without a tree
CONTROL to mirror it into. SysTreeView32 then sends an item-changed
notification carrying
lParam = 0before it holds anything of ours — indistinguishable from “node 0” — and the old guard (node >= tree.len()) no longer caught it, because the model was no longer empty. The posted refresh then indexed a handle table that was still empty and the process aborted. Fixed in three places, each of which would have been enough:- the handle table is sized wherever the model is assigned, so the two cannot drift;
- the refresh is driven off the handle table rather than the model’s length;
- a notification is validated by its
hItem, not by an lParam that an item we did not insert also has. The third pass ofTest-WizardDownload.ps1now walks the whole sequence.
- A panic now says where it happened. This is a GUI binary with
panic = "abort": a panic had nowhere to print, so the window simply vanished and Windows recorded an offset into a stripped exe. A hook inmain.rswritespanicked at <file>:<line>: <message>into the run log — or intopacenotes-installer-crash.logbeside the exe before a game folder is known — and shows it once. Only the first panic opens a window: the modal pumps the message queue, which can deliver the very message that panicked, and one fault was observed becoming a cascade of boxes. It found the bug above on its first run.- What the USER sees is localized and plain: what happened, where it was written, that the game folder is not half-installed, and “Show that file?”, which opens Explorer with the log selected. A report the user cannot find is one they cannot send. The Rust text stays in the file.
- Loaded after re-applying the chosen language: the hook can run on the worker thread, which never had it set.
--selftest-panicandtests/Test-CrashReport.ps1exercise the crash path, which using the installer correctly never does. They assert the exit code Windows reports, that the report names a source file and line, and that a panic before any game folder is settled still leaves a report beside the exe.- NOT done, and deliberately: a
SetUnhandledExceptionFilterfor access violations and other faults that never reach a Rust panic hook. Worth revisiting if a crash ever appears that leaves no panic line.
- Taking a second route in one session no longer starts a second log file. One file per run, not per button press.
- Release build 580 kB against the 600 kB gate. 48/48 layout checks per language, 76/76 flow checks, 7/7 crash checks.
2026/10/26 — installer: two routes, and the log moves into the game folder
- The run log lives in the game folder:
<game dir>\.pacenotes\logs\ install-<utc stamp>.log, beside the ledger, the backups and the restore points it describes. Nothing of ours is written to the user’s profile any more except the download cache, which is shared by every game folder.- One file per run, newest five kept. A single overwritten file meant that re-opening the installer to see what went wrong erased the run being looked at (DESIGN-REVIEW §7 asked for at least three).
- The file is attached when scene 3 settles a folder, and the lines from before that — language, server, catalogue — are replayed into it, so the file is the whole run rather than its second half.
- Downloading and installing are now two screens with the configuration
step between them. Scene 3 offers two routes:
- Download/Update → 4 (pick) → 4b (fetch) → 5 (configure) → 6 (install) → 7.
- Configure/Install → straight to 5, then 6. No server, no transfers: the questions are answered from the cache and the ledger.
- Why: a download fills a cache, an install writes to the game folder. Someone can fetch a voice pack at home and install it on a train, and a user who only wants to change which co-driver is active never has to wait for — or depend on — the server. That is the common case after the first run.
- The two routes are in-page buttons with the sheet’s Next disabled on that scene: they are alternatives, not a primary and an afterthought, and each is one click rather than select-then-Next.
Routedecides only what is SKIPPED. Both routes end in the same questions and the same install, reading the same tree, so there is no second “offline” code path to keep in step.
- Scene 4b is scene 6’s template and handlers at an earlier moment. Cancel belongs to it — a half-finished download costs nothing — and is now DISABLED on scene 6, where every phase writes to the game folder and the engine cannot stop between files; the button could only have lied.
worker:DownloadJob/run_downloadandInstallJob/run_installare two jobs, withFetchedas the download’s own outcome, because a finished download is not a finished run.InstallJobcarries no token and no package list: it installs from the cache and reaches the network only to report a conflict.- Found by the UI suite as soon as the second route existed: scene 5 now defaults to what is already installed. A route whose purpose is changing an install must not ask every question blank — and a selection that omitted an installed package would have uninstalled it. The user’s own pick still wins over the default.
- Tests: the flow suite walks both routes end to end in one run (68/68), asserting the ordering that the split exists for — when the questions are asked the payloads are cached and the game folder has no ledger yet — and that the second run keeps the first run’s log. 44/44 layout checks per language. Release build 573 kB against the 600 kB gate.
2026/10/25 — installer: the client halves of S10, S11 and D2
Three protocol changes landed on the server the same day; these are the
installer sides of them. One commit, because they interleave in
wizard.rs, worker.rs and api.rs and splitting them would mean
commits that do not build.
- Install schema (S11).
build.rsturns everyschemas/<game>.tomlinto a static table (schema.rs: the file’s bytes viainclude_bytes!, plus each category’sid/select/install_root). Read with a hand-rolled strict reader, not thetomlcrate — three string keys in one table array need no parser, nothing parses TOML at runtime, and a schema using more than that fails the BUILD with a line number.- The schema now DRIVES behaviour:
schema::choice_categories(theselect = "one"categories, in file order) decides what scene 4 demands a tick in, what scene 5 asks, and the tree’s category order. Labels stay in the.rctables, mapped by category id. schema::checkcompares the manifest’s per-game schema hash with the embedded copy’s. A hash this build does not know blocks DOWNLOADING for that game only, withIDS_S4_NEEDS_NEWER; reconfiguring, repairing, uninstalling and restore keep working.ALSO_ACCEPTEDholds hashes of revisions that are compatible anyway, so a rollout need not force an update for a corrected comment.- Only checked against a manifest that came FROM THE SERVER, so an offline run rebuilt from the cache never claims to be out of date.
src/schema/tests.rsreadsschemas/rbr.tomloff disk at test time and asserts the compiled-in copy is byte-identical, hashes the same, has no CRLF, and that its single-pick categories are the ones the installer asks a single pick from. The embedded hash and the compiled behaviour cannot drift apart.
- The schema now DRIVES behaviour:
- Key validation via
/whoami(D2)./packagesnever 4xxs — it answers the public listing for a key it does not accept — so the old check could never fail and a mistyped key looked accepted.api::whoamimaps 401 toRejectedand everything else, including a 200 it cannot parse, toUnreachable: a protocol change must not lock a paying user out.KeyStatus.expires_atis used, not ignored — a key within 14 days of expiry raises an informational modal naming the DATE (a date, not a day count: Polish needs three plural forms andload_string_argsubstitutes one%s). - Conflict reports (S10).
http.rsgrewpost_json, sharing one request path withget_streaming.api::report_conflictspostsshared::ConflictReportafter the install — so what is reported is what happened — with no token, no identity and no local paths, trimmed to the server’s 8 KiB cap by measuring (fitting_conflicts) rather than guessing.Overlap::refsconverts the engine’sgame/directory/namekeys to thedirectory/namerefs the report carries and DROPS any key it cannot split, since the server discards bad entries silently. A failed report is a log line; the user’s install succeeded either way. --selftest-report <game>posts a fabricated report through that same code, andTest-HttpClient.ps1now asserts on the JSONL line the server stored (refs in install order, nopcn_, the server’s own timestamp) plus the whole/whoamimatrix: no key, unknown key, live key, and a key minted--expires-days 3. 49/49 against a live server.- Fixed by the suite:
Test-WizardDownload.ps1still demanded a tick inpacenotes, which the schema marksselect = "any". The test was stale, not the gate — an operator adding a folder must not add a question every user is forced to answer. 54/54. - Release build 566 kB against the 600 kB gate.
2026/10/25 — server+shared: conflict reports from installers (S10)
POST /report/conflict, no auth, bodyshared::ConflictReport{game, installer, conflicts: [{file, packages}]}— one entry per shared file, its writers in install order (last = winner). Revised from the flat lists first sketched, which lost which packages fought over which file.- Treated as hostile input: 8 KiB cap (413),
game/installer/each file and ref validated, bad entries dropped and counted (400 only when none survive), nothing echoed back, empty 202 on success. - Rate limited per client (10/h) and globally (600/h) with
Retry-After, checked before the body is parsed. Behind Caddy the peer is always Caddy, so the client comes fromX-Forwarded-For— believed ONLY from a--trusted-proxy(PACENOTES_TRUSTED_PROXIES, IP or CIDR); a forged header from anyone else changes nothing (tested). - Stored as JSONL in
data/reports/conflicts-YYYY-MM-DD.jsonlwith the server’sreceived_at; no IP, key, hostname or local path is written. manifest genwarns per package pair reported in the last 30 days.- Found by the suite: tokio’s
File::write_allmay return before the bytes reach the OS, so the 202 could precede the write; appends now flush. deploy/README.mddocuments the reports, what they contain, and the trusted-proxy setting (10.0.0.0/24on this host).
2026/10/25 — server+shared: per-game install schema (S11)
Replaces the planned “manifest version”. Decided in review: what an installer can fall behind on is the SHAPE of a game’s catalogue — its categories, which ask for one pick, where each installs — not the package list.
schemas/rbr.toml(repository, canonical, byte-exact via.gitattributes -text, ASCII-only) declares RBR’s four categories. Deployed as<data>/<game>/schema.toml.shared:Manifest.schemas: { game: GameSchema { sha256, categories } }.sha256is the hash of the file’s RAW bytes — what installers compare. All fields are plain strings so a newer server can only fail an old installer’s comparison, never its parse.for_gamefilters schemas too.manifest genis strict: no schema → nothing published for that game; unparsable → same; a package in an undeclared category → skipped. The server bundles the repository schemas and warns when a data root’s copy differs (“installers from this release will refuse”).motdandreportsare reserved data-root names.- Upgrade guard:
manifest genREFUSES (exit 1, nothing written) when a game the current manifest serves would vanish because its schema is missing — otherwise the first restart after upgrading would empty the live catalogue. Rehearsed against a copy of the live data root: refused without the schema; with it, all 24 packages published under hashb9c5d124…. - Installer test fixtures (
Test-HttpClient.ps1,Test-WizardDownload.ps1) now ship schema files; not run here (Windows-only). - Installer side recorded in
installer/TASKS.md: embed the schema and compare hashes (a list per game, for rollouts), block downloading only.
Before redeploying the live server: cp schemas/rbr.toml /home/user/pacenotes/data/rbr/schema.toml, then run manifest migrate-to-sidecars
(deploy/README.md, “Upgrading”).
2026/10/25 — server: the manifest is reproducible (DESIGN-REVIEW D5)
manifest genno longer reads the previous manifest: the catalogue is a pure function of zips + sidecars (+ schemas, next). Deleting a sidecar key now actually removes it, and two replicas built from the same tree publish identical manifests.- A package without a sidecar
versiongetssha-<first 8 hex of its sha256>instead of the fixed0.0.0, so two payloads never share a version. - Safety net for the switch:
manifest genREFUSES (exit 1, nothing written, old manifest keeps serving) when a run would drop a value the current manifest carries, naming each one;--accept-metadata-lossoverrides for a deliberate removal. manifest migrate-to-sidecars: one-off move of manifest-only values into sidecars (adds missing keys, never overwrites, never pins0.0.0or a derived version, idempotent). Run once when upgrading (deploy/README.md).deploy/README.mdno longer calls the packages “reproducible” — they are operator content with no other copy; the manifest now genuinely is.
2026/10/25 — server: observability and HTTP hygiene (DESIGN-REVIEW D11, D12, C2)
- D11 One INFO line per response on the
accesstarget: method, path+query, status, bytes, ms, user agent, and the key as its 12-char HASH prefix (-none,malformedjunk) — matchable againsttoken list, never the key. Production runs at INFO, where the oldTraceLayerlogged nothing. Caddy’s HTTPS block gainslog. - D12
Content-Dispositionnames the leaf (Numeric Standard.zip, notstyle/Numeric Standard.zip), with an ASCIIfilenamethat cannot break its quotes and an RFC 6266filename*carrying the exact UTF-8 name. - C2
/packagesisprivate, no-store+Vary: Authorization(it differs per key); protected downloadsprivate, no-store; public onesno-cache. Every download hasETag: "<sha256>"and answersIf-None-Matchwith 304 — only after authorization.
2026/10/25 — server: robustness (DESIGN-REVIEW D10, S4, S5, S7)
- D10 The server handles SIGTERM, not just ctrl-c — as the container’s
PID 1 it previously ignored
podman stopuntil SIGKILL. In-flight requests drain for at most 20 s. Verified live: idle stop 0.01 s; a stop with a 250 s download in flight ends at 20.0 s. The Quadlet setsStopTimeout=30so podman waits for that drain. - S5
manifest.jsonis written atomically (temp + rename). A broken sidecar or a stray zip now withholds only that package: the rest still publishes andmanifest genexits 2 withERROR:lines (1 = nothing written). The entrypoint prints a banner instead of a one-line echo. - S7 Local auth DBs run in WAL mode with a 5 s
busy_timeout, sotoken mintduring traffic cannot 500 a user. - S4 (minimal) A package whose size on disk differs from the manifest is answered 503 “being updated”, not streamed as a short body that fails the client’s sha check as “corrupted”.
2026/10/25 — server: auth correctness (DESIGN-REVIEW D2, D3, D8, D9)
- D3 Download statuses no longer enumerate. On a non-public directory, a caller with no live key gets 401 whether or not the package exists; a live key without the grant gets 404, identical to a missing package. 403 is gone. The installer’s live-server test (anonymous protected download = 401) still holds.
- D2
GET /whoami: 200shared::KeyStatus { expires_at }for a live key, 401 for none/malformed/unknown/expired/revoked./packagescan never answer “is this key valid?” (it falls back to the public listing), so the installer’s scene-2 check was dead code; it moves to/whoami(installer/TASKS.mdI8). - D8 Token rows decode fail-closed. Found while writing the test:
libsql 0.6’s typed
row.get::<i64>PANICS on a type mismatch rather than erroring, so drift would have killed the request. Decoding now goes throughget_valuewith explicit matching. - D9 Public downloads never touch the auth DB;
/packagesserves the public listing when the DB fails; a bearer value not shaped like a minted key is rejected before any query. Proven by a test that drops the auth tables mid-run. - 401 responses now carry
WWW-Authenticate: Bearer.
2026/10/13 — deployable server (deploy/)
Everything needed to put a real server in front of the installer for testing:
deploy/Containerfile— multi-stage build (rust:1-slim → debian-slim), binds0.0.0.0:8080because the CLI’s loopback default would be unreachable from Caddy, and pinsCARGO_BUILD_JOBS=1for the 3 GB host.deploy/entrypoint.sh—manifest genthenserve, so publishing a package is “copy the zip in, restart the unit”. A failed generation leaves the previous manifest serving rather than taking the server down.deploy/pacenotes.container+pacenotes.config.env— Podman Quadlet following the host’sexample.containerpattern (clone branch → build → discard checkout), with one volume (~/pacenotes/data→/data), a healthcheck on/health, and no published port: Caddy is the only route in and terminates TLS. Validated with podman’s own quadlet generator.deploy/Caddyfile.snippet—pacenotes.example.comreverse-proxying topacenotes:8080, plus the optional internalhttp://block.deploy/README.md— configuration and deployment walkthrough: data layout, publishing, sidecars, MOTD, access keys, installer test commands, operations table and troubleshooting.libsqllost itsreplicationfeature (tonic/tonic-web/prost-build/ libsql_replication): embedded replicas are not something this server creates, and the dependency tree was the bulk of the image build.core+remote+tlskeep Turso-by---db-urlintact. Tests unchanged and green.
Two departures from the example.container pattern, both deliberate:
podman image prune -f without --external (the --external form deletes
concurrent builds’ intermediate containers — see below), and
StartLimitBurst=3/StartLimitIntervalSec=1h so a crash cannot loop a
release build.
2026/10/12 — one server, every game
Decided with the maintainer: one instance serves all games, and the first folder level under the data root names the game.
- Layout is now
<data>/<game>/packages/<directory>/<name>.zip, with<data>/motd/<lang>.txtbeside the games and one<data>/manifest.jsoncovering all of them.serve/manifest gentake--data(PACENOTES_DATA_DIR, default./data) in place of--packages. PackageInfo.gamecomes from that folder, sogameis no longer a sidecar key: a sidecar still carrying it fails the run. Nothing positional comes from a .toml any more.GET /packages?game=<id>filters to one game; without it the document covers every game. An unknown game is an empty list, not an error.GET /download/{game}/{directory}/{*name}: the game leads the address, because two games can hold the same directory+name — without it the route would have silently served the wrong game’s bytes. All three segments stay lookup keys; a miss on any is 404.sharedgainedvalid_id(one rule for games and access directories),KNOWN_GAMES,Manifest::for_gameandPackageInfo::download_path.manifest genwarns on the easy mis-filings (category where an access directory belongs, category/publicwhere a game belongs, game folder with nopackages/, unknown game id, name needing percent-encoding).- The real
server/datatree was re-filed underpublic/: 24 RBR packages now publish with correct categories, and a 28 MB nested download plus a percent-encoded name (style/Numeric%20Standard) were verified over HTTP.
INSTALLER SIDE, not done here: the download URL gained a leading game
segment, /packages can be filtered by game, and a cache/ledger key of
“directory/name” is no longer unique across games — use
PackageInfo::download_path() or prefix the game.
2026/10/12 — server side of protocol rev. 2
manifest genrecurses: a package’snameis now its path under the access directory without.zip(packages/public/codriver/polish/male/Kamil.zip→public+codriver/polish/male/Kamil), so the operator’s folder layout is the installer’s Download tree. Names are validated withshared::valid_name; rejected or non-UTF-8 names are skipped with a warning instead of entering the manifest.GET /download/{directory}/{*name}is a wildcard route so nested names match. The security invariant is untouched: both halves stay manifest lookup keys, only a hit yields a path on disk, and every miss — including percent-encoded traversal — is 404.GET /motd?lang=xxserves<packages>/motd/<lang>.txtas text/plain, falls back toen, 404s when absent (the installer treats that like being offline) and needs no credential.langis restricted to[a-z]{2,8}before it touches the filesystem.manifest gennow warns when a top-level folder is a known category name, i.e. when the access-directory level has been left out — the mistake the realserver/datatree had.- Sidecars lost their last positional key:
gameaside, nothing about where a package sits comes from a .toml any more.
2026/09/20 — M3: server auth
- libSQL auth store:
db.rsopens--db-url(local file,:memory:, or remote Turso by URL withPACENOTES_DB_AUTH_TOKEN) and appliesmigrations/0001_init.sqlidempotently on every start. auth.rs: bearer parsing, sha256 hashing, minting (pcn_+ 32 random bytes, hand-rolled unpadded base64url — no dependency for 20 lines), and a single-querytokens LEFT JOIN grantsload.TokenRow::is_liveis the one definition of “not revoked, not expired”, shared bydecideand the/packagesfilter./packagesfilters to public + granted directories and never 4xxs (a bad credential silently sees the anonymous listing, so directory names cannot be enumerated)./downloadenforcesdecide(): 401 missing/expired/ revoked, 403 valid-but-not-granted, 404 no such package — resolved before the access check, so a nonexistent package is 404 for everyone.tokenadmin CLI:mint(prints the key once, stdout),grant,ungrant,revoke,list. Credentials are addressed by raw key or hash (full or the short formlistprints);publicand invalid directory names are refused; an ambiguous hash prefix is an error.serversplit into lib + bin soserver/tests/api.rsdrives the real router: the {anon, no-grants, dirA, dirA+dirB} x {public, dirA, dirB} matrix for listing AND download, plus expired, revoked, ungrant-takes- effect, traversal and ApiError bodies. 35 tests green, clippy -D warnings clean.- Verified live: minting, listing, refusal to grant
public, per-grant filtering, 401/403/200 on download, revoke taking effect — and the log contains neither the key nor the Authorization header.
2026/09/20 — M1a (protocol v2) + M2 (server MVP)
shared: protocol v2.PackageInfogainsgame,category,display_name,exclusive_group,requires; newPackageRef(“directory/name” — the download path, cache key, ledger key andrequiresformat in one shape),PackageInfo::label()/required_refs(),Manifest::get(). Old (v1) manifests still deserialize via serde defaults, and absent optionals are not serialized, so the JSON stays hand-readable.gameis not in the written M1a list:installer/DESIGN.mdmade the app multi-game, and without it every package would appear under every ribbon tab.server: M2 complete.GET /packages(public-only for now) andGET /download/{directory}/{name}streaming viatokio_util::io::ReaderStream;manifest genscans<dir>/<directory>/*.zip, hashes each zip and merges its<name>.tomlsidecar (precedence: sidecar > previous manifest > protocol default). A malformed sidecar aborts the run; danglingrequires, sidecars without a zip, public-requires-protected and cross-game dependencies are collected warnings.- Verified end-to-end with curl: health 200; anon listing shows only
public; download byte-identical with correct Content-Length/Type/Disposition; unknown package and..%2F..%2Fdata/auth.dbboth 404. - KNOWN GAP:
/downloadis not access-checked yet (M3/S6)./packageshides protected directory names, but that is concealment, not access control — do not deploy before M3.
2026/09/18 — M0/M1 done (server side)
- All workspace dependencies pinned to exact versions;
clapgained theenvfeature so thePACENOTES_*fallbacks compile.cargo build,cargo clippy -D warningsandcargo testare green for shared+server. - GitHub Actions ported from
legacy/.github/workflows(C#/dotnet → Rust):ci.yml(new): fmt + clippy + test for shared/server on Linux, installer test + release build + 500 kB size gate on Windows.cd-debugbuild-manually.yml: manual debug artifact (exe + pdb).cd-deploy-manually.yml: manual release — version frominstaller/Cargo.toml, tag-exists check, size gate, GitHub release.cd-deploy-on-pull-request.yml: same, on merged PR to master. Supabase secret injection dropped (no Supabase); the archivedactions/create-release+upload-release-assetpair replaced withsoftprops/action-gh-release.
2026/09/18
There was an error with the installer package.
error: current package believes it's in a workspace when it's not:
current: E:\Git\RBR\CoPilot_InstallerV2\installer\Cargo.toml
workspace: E:\Git\RBR\CoPilot_InstallerV2\Cargo.toml
this may be fixable by adding `installer` to the `workspace.members` array of the manifest located at: E:\Git\RBR\CoPilot_InstallerV2\Cargo.toml
Alternatively, to keep it out of the workspace, add the package to the `workspace.exclude` array, or add an empty `[workspace]` table to the package's manifest.
Patched with addition of “workspace.exclude” in top-level Cargo.toml
