Files
m2dev-client/docs/update-manager.md
2026-04-14 13:52:27 +02:00

18 KiB
Raw Permalink Blame History

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 for the formal schema. Summary:

{
  "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.
  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 (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:
    ./scripts/make-release.sh --source /path/to/fresh/client --version 2026.04.14-1 \
        --previous 2026.04.13-3 --notes notes.md --dry-run
    
    scripts/make-release.sh is the single entry point for the v1 manual flow. It drives make-manifest.py + sign-manifest.py, builds the content-addressed blob tree under files/<hash[0:2]>/<hash> with hardlink-based deduplication, archives the signed manifest into manifests/<version>.json, and — unless --dry-run is passed — rsyncs the blob tree first and the manifest.json + manifest.json.sig pair last so the release becomes visible atomically. Flags: --key (default ~/.config/metin/launcher-signing-key, must be mode 600), --out (default /tmp/release-<version>), --force to overwrite a non-empty out dir, --yes to skip the interactive rsync confirmation, --rsync-target <user@host:/path> to override the upload destination.
  2. The script walks the client directory, computes sha256 for each file, writes a manifest.json, signs it, and produces a release directory containing the manifest, its signature, and the deduplicated blob tree.
  3. Human review: diff the new manifest against the previous one, sanity-check size and file count.
  4. Re-run without --dry-run (same args) to rsync to the VPS. The script prints the target and waits for confirmation unless --yes is passed.
  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 (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 — 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 (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 (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 is overkill for a 4-dev private-server project, but worth naming as the professional vocabulary. 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 110, 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.