forked from metin-server/m2dev-client
After a survey of existing Metin2 launchers, general-purpose auto-updaters, and adjacent open-source game launchers, update the design to: - drop the hand-rolled rename-before-replace self-update path - use Velopack for launcher self-update (MIT, modern successor to Squirrel.Windows, handles atomic replace, delta, Authenticode, AV friendliness out of the box) - keep the custom asset patcher for the 4 GB game payload, which Velopack is not designed for - reference runelite/launcher as the architectural template - name Sparkle 2 and wowemulation-dev/wow-patcher as Ed25519 prior art No Metin2 community launcher is worth forking; the ceiling of published prior art is 'file list + sha256 + HTTP GET' and this design is already above it. Greenfield confirmed.
252 lines
18 KiB
Markdown
252 lines
18 KiB
Markdown
# Update manager — design
|
||
|
||
This is the design for how the Metin2 client gets updated after the player's first install. Scope covers the launcher, the server-side manifest, the publishing flow, and the security model. Implementation plan is at the bottom.
|
||
|
||
## Goals and constraints
|
||
|
||
- The **base install is large** (~4.3 GB of packs + binaries). Shipping it through the update channel is a non-goal; base install is a separate bundled download.
|
||
- Releases can happen **as often as daily**. A small script change in a Python pack should not force players to re-download the full client.
|
||
- The update must be **atomic from the player's point of view**: they end up either on the old version or on the new one, never on a half-patched client.
|
||
- **Integrity matters**: a malicious or buggy mirror must not be able to ship tampered files.
|
||
- **Offline fallback**: if the update server is unreachable, the launcher lets the player into the game with whatever they have.
|
||
- The launcher is the **single entry point** the player runs. It owns update detection, download, integrity checks, self-update, and game launch.
|
||
- Publishing is **manual for v1** (`make-release.sh` + rsync), automated via Gitea Actions once the flow is proven.
|
||
|
||
## High-level architecture
|
||
|
||
```
|
||
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||
│ Player machine │ HTTPS │ VPS (Caddy) │
|
||
│ ├────────► │
|
||
│ Launcher.exe │ │ updates.jakubkadlec.dev/ │
|
||
│ ├─ fetch manifest │ │ manifest.json │
|
||
│ ├─ verify Ed25519 signature │ │ manifest.json.sig │
|
||
│ ├─ diff with local files │ │ files/<hash>/<hash> │
|
||
│ ├─ download missing files │ │ │
|
||
│ ├─ verify each sha256 │ └──────────────────────────────┘
|
||
│ ├─ atomic move into place │
|
||
│ ├─ self-update if needed │
|
||
│ └─ launch Metin2.exe │
|
||
│ │
|
||
│ client/ │
|
||
│ Metin2.exe │
|
||
│ Metin2Launcher.exe │
|
||
│ pack/*.pck assets/* ... │
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
### Server-side layout
|
||
|
||
Served statically by Caddy from `/var/www/updates.jakubkadlec.dev/`:
|
||
|
||
```
|
||
updates.jakubkadlec.dev/
|
||
├── manifest.json ← current release manifest
|
||
├── manifest.json.sig ← Ed25519 signature over manifest.json
|
||
├── manifests/
|
||
│ ├── 2026.04.14-1.json ← archived historical manifests
|
||
│ ├── 2026.04.14-1.json.sig
|
||
│ └── ...
|
||
└── files/
|
||
└── ab/
|
||
└── abc123...def ← content-addressed blob, named after sha256
|
||
```
|
||
|
||
**Content-addressed storage** means a file is named after its sha256. Two consequences:
|
||
|
||
- **Automatic deduplication** across releases: if `item.pck` is unchanged, the new manifest points at the same blob. Nothing is uploaded or stored twice.
|
||
- **Atomic publishing**: upload new blobs first, then replace `manifest.json` last. A partially-uploaded release never causes an inconsistent client state, because the client never sees the new manifest until it's complete.
|
||
|
||
### Manifest
|
||
|
||
See [update-manifest.md](./update-manifest.md) for the formal schema. Summary:
|
||
|
||
```json
|
||
{
|
||
"version": "2026.04.14-1",
|
||
"created_at": "2026-04-14T12:00:00Z",
|
||
"previous": "2026.04.13-3",
|
||
"launcher": {
|
||
"path": "Metin2Launcher.exe",
|
||
"sha256": "..."
|
||
},
|
||
"files": [
|
||
{
|
||
"path": "Metin2.exe",
|
||
"sha256": "...",
|
||
"size": 27982848,
|
||
"platform": "windows",
|
||
"required": true
|
||
},
|
||
{
|
||
"path": "pack/item.pck",
|
||
"sha256": "...",
|
||
"size": 128000000
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- `version` is date-based (`YYYY.MM.DD-N` where `N` is the daily counter). Human-readable, sortable, forgiving of multiple releases per day.
|
||
- `previous` lets the launcher show a changelog chain and enables smarter diff strategies later.
|
||
- `launcher` is called out separately because it needs special handling (self-update).
|
||
- `platform` is `windows` by default; future native Linux build can use `linux` and the launcher filters by its own platform.
|
||
- `required: true` files block game launch if missing; optional files (language packs, optional assets) are opportunistic.
|
||
|
||
### Security model
|
||
|
||
- A single **Ed25519 keypair** signs each manifest. Private key lives on the release machine only (never in any repo). Public key is compiled into the launcher binary.
|
||
- Launcher **refuses to apply** a manifest whose signature doesn't verify against the baked-in public key. No fallback, no "accept this once" dialog.
|
||
- **sha256 per file** catches storage or transport corruption. A file whose downloaded bytes don't match the manifest hash is discarded and retried.
|
||
- **Key rotation** flow: ship a new launcher that knows both the old and new public keys, transition period of a week, then ship one that only knows the new key. Because the launcher itself is delivered through the same update channel, this is clean.
|
||
- **Transport** is HTTPS via Caddy (Let's Encrypt already). Ed25519 signing is defense-in-depth against compromised CDN / MITM, not the primary trust mechanism.
|
||
|
||
### Client behavior
|
||
|
||
Launcher does, in order:
|
||
|
||
1. **Fetch** `manifest.json` and `manifest.json.sig` (HTTP GET, timeout 10 s).
|
||
2. **Verify** signature. On failure: abort update, log, go to step 8.
|
||
3. **Parse** manifest, filter `files[]` by matching `platform`.
|
||
4. For each file:
|
||
- **Hash** the local copy (if present). If sha256 matches, skip.
|
||
- Otherwise **download** the blob from `files/<hash[0:2]>/<hash>` into `staging/<path>` using HTTP Range requests (to resume partial downloads from a prior interrupted run).
|
||
- **Verify** downloaded bytes against manifest hash. Mismatch = delete staging file, mark file as failed.
|
||
5. If any **required** file failed after N retries: abort update, log, go to step 8 (offline fallback). Optional files that failed are silently skipped.
|
||
6. **Self-update check**: if `launcher.sha256` differs from our own running binary, write the new launcher to `Metin2Launcher.new.exe`, spawn a small **trampoline** that waits for our PID to exit, replaces `Metin2Launcher.exe` with `Metin2Launcher.new.exe`, then exits. We then exit ourselves; the trampoline is a tiny native exe that lives alongside the launcher. See [Self-update details](#self-update-details).
|
||
7. **Atomic apply**: for each non-launcher file, `MoveFileEx(staging, final, MOVEFILE_REPLACE_EXISTING)`. Keep a small manifest of moved paths so we can roll back on failure.
|
||
8. **Launch**: `CreateProcess("Metin2.exe", ...)` with the current working directory at the client root. Exit the launcher once the game process has established itself.
|
||
|
||
### Self-update details
|
||
|
||
We do not implement self-update from scratch. The launcher embeds **[Velopack](https://github.com/velopack/velopack)** (MIT, Rust+.NET, actively maintained), which handles:
|
||
|
||
- Atomic replacement of the running launcher binary (stable install path, unlike legacy Squirrel)
|
||
- Delta patches between launcher versions
|
||
- Authenticode signature verification
|
||
- Antivirus / firewall friendliness (no UAC prompt, no path churn)
|
||
- ~2s update + relaunch
|
||
|
||
Velopack is used **only for the launcher binary itself**, which is small (~15 MB). The 4 GB game assets are handled by our own patcher code — Velopack is explicitly not designed for payloads that large.
|
||
|
||
Practical shape: at launcher startup we call `VelopackApp.Build().Run()`, then later `UpdateManager.CheckForUpdatesAsync()` against a separate Velopack release feed that lives alongside our asset manifest (e.g. `updates.jakubkadlec.dev/launcher/`). If a new launcher version is available, Velopack downloads it in the background and applies it on next restart. The asset update (sha256 manifest walk) runs unconditionally regardless of whether the launcher itself is updating.
|
||
|
||
The fallback path if Velopack ever fails — rename-before-replace plus a small `launcher-update.exe` trampoline — is documented but not implemented in the MVP. Velopack has been stable enough in production for us to start without it.
|
||
|
||
### Offline fallback
|
||
|
||
- If step 1 times out or returns non-2xx, launcher logs the failure and goes straight to step 8. The player gets into the game with whatever local version they already have.
|
||
- If signature verification (step 2) fails, launcher does **not** fall back silently — it shows an error and refuses to launch, because "the server is lying to me" is more dangerous than "the server is down". This is the one case where we stop the player.
|
||
- If the game server is down but the update server is up, that's the server runtime team's problem; the launcher is still successful.
|
||
|
||
### Directory layout on the player's machine
|
||
|
||
```
|
||
client/
|
||
├── Metin2Launcher.exe ← self-updating launcher, the player's entry point
|
||
├── Metin2.exe ← managed by the launcher
|
||
├── Metin2Launcher.exe.old ← previous launcher, kept for rollback (deleted after 1 successful run)
|
||
├── Metin2.exe.old ← same for Metin2.exe
|
||
├── pack/
|
||
├── assets/
|
||
├── config/
|
||
├── log/
|
||
└── .updates/
|
||
├── current-manifest.json ← the manifest we're currently on
|
||
├── staging/ ← download staging area, cleared after successful apply
|
||
└── launcher.log ← launcher's own log
|
||
```
|
||
|
||
Files under `.updates/` are created by the launcher. The user shouldn't touch them and we ship a `.gitignore` so they don't end up in any accidental archive.
|
||
|
||
## Publishing flow (v1, manual)
|
||
|
||
1. On a trusted machine (not random laptop), with the private signing key present:
|
||
```bash
|
||
./scripts/make-release.sh --version 2026.04.14-1 --source /path/to/fresh/client
|
||
```
|
||
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory `release/2026.04.14-1/` containing the manifest, its signature, and only the new blobs (ones not already present on the server).
|
||
3. Human review: diff the new manifest against the previous one, sanity-check size and file count.
|
||
4. `rsync` the release directory to the VPS:
|
||
```bash
|
||
rsync -av release/2026.04.14-1/ mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/
|
||
```
|
||
5. Verify from a second machine: `curl` the manifest, check signature, check a random blob.
|
||
6. Tag the release in git.
|
||
|
||
Manual because v1 should let us feel the flow before we automate. After ~2 weeks of successful manual releases, wire it into Gitea Actions.
|
||
|
||
## Publishing flow (v2, Gitea Actions)
|
||
|
||
Not implemented in MVP. Sketch:
|
||
|
||
- `m2dev-client-src` build artifact (Metin2.exe) and `m2dev-client` runtime content are combined by a release workflow.
|
||
- The workflow runs `make-release.sh` using a signing key stored as a Gitea secret.
|
||
- rsyncs to VPS via a deploy SSH key.
|
||
- Opens a PR that updates `CHANGELOG.md` with the new version.
|
||
|
||
Trade-off: automation speed vs. the attack surface of a CI-held signing key. When we get there, we'll probably **sign offline** and let CI only publish pre-signed bundles.
|
||
|
||
## Failure modes and what we do about them
|
||
|
||
| Failure | Client behavior | Operator behavior |
|
||
|---|---|---|
|
||
| Update server 5xx | Launch game with current version | Investigate VPS / Caddy |
|
||
| Update server returns invalid signature | Refuse to launch, show error | Rotate signing key, investigate source |
|
||
| Partial download (network drop) | Resume on next run via Range | None, user retries |
|
||
| Individual file hash mismatch after retries | Skip file if optional, abort if required | Investigate blob corruption |
|
||
| Launcher self-update fails mid-replace | Rollback from `.old` copy, launch old launcher | Investigate, ship fixed launcher |
|
||
| Player filesystem is full | Error out with actionable message ("free X MB, retry") | None |
|
||
| Player has antivirus quarantining files | Error message naming the file that disappeared | Document, whitelist in launcher installer |
|
||
| Someone ships a manifest with missing blobs | Launcher reports which files it can't fetch | Broken release, re-run publish |
|
||
|
||
## Prior art survey
|
||
|
||
Before writing code, a scan of the ecosystem for things to fork or copy. Bottom line: nothing in the Metin2 community is worth forking, but three external projects inform this design.
|
||
|
||
**Metin2-specific launchers**: the community reference is [Karbust/Metin2-Patcher-Electron](https://github.com/Karbust/Metin2-Patcher-Electron) (TypeScript/Electron, MIT, last push 2021). SHA256 per-file manifest, parallel HTTP downloads, two-zip deploy model. No signing, no delta, no self-update, dead deps. Worth skimming to understand what Metin2 server admins UX-expect. Everything else in the space (`VoidEngineCC/Metin2-Simple-C-Patcher`, `CeerdaN/metin2-patcher-electron`, `Cankira-BK/metin2-pvp-launcher`, ...) is either unlicensed, a toy, or abandoned. The ceiling of published prior art is "file list + sha256 + HTTP GET." We are already above it on paper.
|
||
|
||
**d1str4ught upstream**: no launcher, no patcher. The upstream distribution model is "clone the repo." Greenfield for us.
|
||
|
||
**General-purpose auto-updaters**:
|
||
|
||
- **[Velopack](https://github.com/velopack/velopack)** — the modern successor to Squirrel.Windows, by the same primary author. MIT, Rust+.NET, released regularly in 2025. Handles atomic binary replacement, delta patches, Authenticode, stable install paths. Used for the launcher self-update layer. Not used for game assets — not designed for 4 GB payloads.
|
||
- Squirrel.Windows (legacy, unmaintained, known path-churn bugs), WiX Burn (wrong shape — chain installer, not update loop), NSIS (you reimplement everything), Rust `self_update` crate (single-file, no multi-artifact) — all rejected.
|
||
|
||
**Architectural reference**: **[runelite/launcher](https://github.com/runelite/launcher)** (Java, BSD-2). A tiny native launcher for a non-Steam game, with exactly the shape we want: bootstrap JSON → signed → list of artifacts with hashes → download missing → verify → launch. X.509 instead of Ed25519, same threat model. Before writing launcher code we read this end-to-end as the reference implementation; we do not copy code (wrong language), we copy structure.
|
||
|
||
**Ed25519 prior art in private-server game launchers**: [wowemulation-dev/wow-patcher](https://github.com/wowemulation-dev/wow-patcher) (Rust) replaces the WoW client's Ed25519 public key to redirect auth. Direct precedent for using Ed25519 in this role. Sparkle 2 on macOS has shipped Ed25519 appcast since 2021; same primitive, coarser per-release granularity.
|
||
|
||
**Manifest format**: the shape we have is loosely TUF-lite — one signed top-level JSON pointing at content-addressed blobs, without TUF's role separation. Full [TUF](https://theupdateframework.io/) is overkill for a 4-dev private-server project, but worth naming as the professional vocabulary. [OSTree](https://ostreedev.github.io/ostree/) implements exactly the content-addressed part at the filesystem level — a good read, too Linux-specific to reuse.
|
||
|
||
**Net take**: this design converges on the intersection of OSTree (content addressing), Sparkle (Ed25519 signing) and RuneLite launcher (bootstrap-signed-JSON → artifact list → verify → launch), with Velopack handling the self-update plumbing. Nothing novel, which is the point.
|
||
|
||
## Implementation plan
|
||
|
||
Effort is real-days of Claude + review time from the team.
|
||
|
||
| # | Task | Effort | Output |
|
||
|---|---|---|---|
|
||
| 1 | This design doc, reviewed | 0.5 d | `docs/update-manager.md` |
|
||
| 2 | Manifest schema spec | 0.5 d | `docs/update-manifest.md` |
|
||
| 3 | `scripts/make-manifest.py` — walk dir, produce unsigned manifest | 1 d | Python script + docs |
|
||
| 4 | Sign/verify script (Ed25519) | 0.5 d | Python + keygen docs |
|
||
| 5 | Caddy config for `updates.jakubkadlec.dev` | 0.5 d | Caddyfile fragment + DNS note |
|
||
| 6 | Launcher (C# .NET 8 self-contained, single-file) — skeleton + HTTP fetch + manifest parse + Ed25519 verify | 2 d | `launcher/` project |
|
||
| 7 | Launcher — file diff + download + hash verify + atomic apply | 2 d | |
|
||
| 8 | Launcher — Velopack integration for self-update | 0.5 d | |
|
||
| 9 | End-to-end test (publish → client updates → launch) | 1 d | |
|
||
| 10 | `scripts/make-release.sh` wiring it all together | 1 d | |
|
||
| 11 | Docs: publisher runbook, player troubleshooting, threat model | 1 d | |
|
||
|
||
**MVP is items 1–10**, roughly **10 working days** of implementation. Review + integration + real-world hardening on top.
|
||
|
||
## Open questions left for the team
|
||
|
||
- **Launcher UI**: bare minimum (single window with a progress bar and "Play" button) vs. something nicer (changelog panel, news feed, image banner)? MVP is bare minimum; richer UI is a v2 concern.
|
||
- **Localization**: manifest fields are English, but the launcher UI needs Czech (at least). Load strings from the client's existing `locale.pck`, or ship a separate small locale for the launcher? Lean toward the latter because launcher runs before the game and shouldn't depend on game assets.
|
||
- **News feed**: optional. If yes, add a `news_url` field to the manifest and let the launcher fetch a small JSON blob. Nice-to-have.
|
||
- **Analytics**: do we want to know how many players are on which version? Simple: launcher sends an HTTP POST with `{version, platform}` after successful update. Requires GDPR thought. Off by default, opt-in.
|
||
|
||
None of these block the MVP — they can be decided once the skeleton works.
|