FishMMO Updater
The FishMMO Updater is a standalone .NET 8 console executable that brings a FishMMO client up-to-date by applying versioned binary patches. It is launched by the FishMMO launcher when an update is required, takes ownership of the update transaction, optionally restarts the client when finished, and exits.
The updater is transactional: every file is processed with a backup, and any critical failure triggers a rollback to the pre-patch state.
Build & ship together with patches. Always include
Updater.exe(or the platform equivalent) in your release, but generate patches before packaging so the launcher has something to apply.
Table of Contents
- Description
- Supported Platforms
- Architecture
- Key Components
- Patch File Structure
- Configuration
- Command-Line Arguments
- Usage Example
- Logging
- Build
- Flow Diagram
Description
The updater applies one patch archive per run: the single file that upgrades
directly from -version to -latestversion (Patches/<from>-<to>.zip). It does
not chain intermediate steps — the patch server indexes and serves a direct
archive for each supported source version, and if no such archive exists the
update cannot proceed (the launcher surfaces this as PatchUnavailable and asks
the player to reinstall). The archive contains:
- A
manifest.jsondescribing the per-file operations (new, modified, deleted) including the source hash, the target hash, and (for modified files) a binary diff payload. - The raw bytes / diff payloads referenced by the manifest.
For modified files the updater verifies the pre-patch hash against the file on disk, applies the binary diff, and verifies the post-patch hash before swapping the file into place.
Supported Platforms
| Target | Status |
|---|---|
| .NET 8.0 — Windows | Yes |
| .NET 8.0 — Linux | Yes |
| .NET 8.0 — macOS | Yes |
The updater is designed to be called by the FishMMO launcher, but it also runs standalone for manual updates and for CI validation.
Architecture
FishMMO-Patcher/Updater/
├── Program.cs # Entry point: argument parsing, orchestration, restart
├── Patch/ # Patch loading, manifest model, hash + diff utilities
├── Updater.csproj
└── Updater.sln
Patching is performed in a single pass with these phases:
- Argument parsing — current version, target version, launcher PID, exe to restart.
- Launcher shutdown — graceful request, fall back to a forced kill (see below).
- Patch application — one archive, parallel new/modified file handling, sequential deletes.
- Move-into-place — temporary
.newfiles are atomically moved over the originals. - Rollback — on any unrecoverable error, restore every backup taken in this run.
- Cleanup — remove temporary files and backups; on success, delete the consumed archive.
- Restart — kill the PID again as a safety net, optionally start the configured executable, then exit.
Shutting the client down
Patching a live install corrupts it, so the client must be gone before any file is touched. The request is platform-specific:
| Platform | Graceful request | Fallback |
|---|---|---|
| Windows | Process.CloseMainWindow() |
Process.Kill() |
| Linux / macOS | kill(pid, SIGTERM) via P/Invoke to libc |
Process.Kill() (SIGKILL) |
CloseMainWindow is Windows-only and throws PlatformNotSupportedException
elsewhere, so it cannot be the single path. Every route where the graceful
request is refused, undeliverable, or ignored past the timeout falls through to
the forced kill — an ungraceful client shutdown is far cheaper than patching
files underneath a running process.
Failure semantics
ApplyPatchFile reports success or failure, and the two outcomes differ in what
is left on disk:
| Outcome | Install state | Patches/<from>-<to>.zip |
|---|---|---|
| Applied | Upgraded to -latestversion |
Deleted, so Patches/ does not accumulate every update ever installed |
| Rolled back | Still on the old version | Kept, so a retry does not have to re-download it |
The updater always terminates with exit code 0 and always attempts to restart
-exe, on both outcomes. Success is reported on the console, not through the
exit code, and the launcher does not read it — the updater kills the launcher
before patching, so there is no launcher left to observe the result. After a
failed apply the restarted client is still on the old version and its next
version check re-enters the update flow.
Key Components
| Component | Responsibility |
|---|---|
Program.Main |
Argument parsing, orchestration, restart handoff. |
KillLauncherProcess |
Locates the launcher process by PID and shuts it down (graceful → forceful). |
TryRequestGracefulExit |
Platform-appropriate graceful shutdown request: CloseMainWindow on Windows, kill(SIGTERM) on POSIX. |
Patch/ manifest reader |
Loads manifest.json out of the patch ZIP. |
| New-file writer | Streams new files from the ZIP into the target tree (parallelized). |
| Binary diff applier | Applies binary diffs to existing files, with hash verification on both sides. |
| Deletion handler | Removes files marked for deletion in the manifest. |
| Backup / rollback | Per-file .bak taken before any write; replayed in reverse on failure. |
| Restart hook | Optionally starts the client executable post-patch. |
Patch File Structure
Patch archives are ZIP files inside the Patches/ directory next to the
updater executable. Naming convention:
Patches/<oldVersion>-<newVersion>.zip
Three-way contract. This directory name and file-name scheme are shared by three independently built components and must be changed in all three at once:
Component Resolves it as Unity client (launcher, downloads here) Constants.GetPatchesDirectory()/Constants.GetPatchFileName(from, to)Updater (reads here) AppDomain.CurrentDomain.BaseDirectory+Patches, hard-coded — it cannot reference the Unity assemblyPatcher web server (indexes and serves) Patches:DirectoryNameinappsettings.json+ the index regexBoth client processes run from the install root, so the first two agree. If they ever diverge, every update silently no-ops: the launcher downloads to one place, the updater finds nothing at the other, and the client relaunches at the same version forever.
Archive contents:
| Entry | Purpose |
|---|---|
manifest.json |
Describes New, Modified, Deleted operations with hashes and (for modified) diff entry names. |
| File payloads | Raw bytes for new files. |
| Diff payloads | Binary diff payloads for modified files. |
Configuration
The updater has no external configuration file — behavior is controlled by command-line arguments and a small set of internal defaults that can be overridden by editing the source.
| Internal option | Default | Description |
|---|---|---|
MaxFileOperationRetries |
5 |
Retry count for transient file I/O errors. |
FileOperationRetryDelayMs |
200 |
Delay between retries. |
PatchesDirectory |
Patches |
Directory (relative to the updater's base directory) holding patch ZIPs. Overridable at runtime with -patches=. |
GracefulExitTimeoutMs |
10000 |
How long to wait for the client to exit after the graceful request before forcing a kill. |
ForceKillTimeoutMs |
5000 |
How long to wait for the process to disappear after Kill(). |
PostKillSettleMs |
500 |
Settle delay after shutdown, so the OS releases file handles before patching. |
Command-Line Arguments
| Argument | Required | Description |
|---|---|---|
-version=<currentVersion> |
Yes | The version currently installed. |
-latestversion=<latestVersion> |
Yes | The target version to upgrade to. |
-pid=<launcherPID> |
Yes | Process ID of the launcher; updater will close/kill it before patching. |
-exe=<executablePath> |
Optional | Path to the client executable to start when the updater is done, relative to the updater's base directory. |
-patches=<absoluteDir> |
Optional | Directory to read patch archives from. Defaults to Patches under the updater's base directory. |
Arguments are matched by prefix, so -version also matches -versionfoo; pass
them exactly as listed. If -version and -latestversion are equal the updater
does nothing but restart -exe. If the single archive <version>-<latestversion>.zip
is missing from the patches directory, it reports the missing file and restarts the
client unchanged.
-patches must be absolute. A relative path resolves against whatever the current
directory happens to be, which is not guaranteed to be the install root when the updater
is started by the OS rather than by the launcher — so the same string could name two
different folders. A path that is relative, missing, or unreadable is ignored with a
warning and the default is used; the launcher applies the identical rule to its own
setting, so both sides fall back together rather than to different places.
This argument only changes where a verified archive is read from. The launcher hashes the download against the server-supplied SHA-256 before the updater is invoked at all, and anyone able to write the launcher's configuration file could equally drop a file into the default location — so redirecting it does not weaken the integrity check.
It does not relocate the install. The updater patches files relative to its own directory and ships beside the client binaries, so the install root is fixed by construction; moving an install means moving the updater with it.
Usage Example
# Linux
./Updater -version=1.0.0 -latestversion=1.1.0 -pid=1234 -exe=FishMMOClient
# Windows
Updater.exe -version=1.0.0 -latestversion=1.1.0 -pid=1234 -exe=FishMMOClient.exe
This call:
- Shuts down launcher PID
1234— graceful request first, forced kill if it does not exit in time. - Looks for exactly
Patches/1.0.0-1.1.0.zip. - Applies it transactionally.
- On success, deletes the archive; on failure, rolls back and keeps it.
- Starts
FishMMOClient(.exe)either way and exits with code0.
Logging
All actions, warnings, and errors are written to the console. Log lines include: file path, operation (new/modified/deleted), pre- and post-hash, retry counts, and rollback decisions.
The launcher does not capture this output. It hands off and shuts down (the updater kills it by PID regardless), so the console is the operator's and the player's only view of what happened. Run the updater from a terminal when diagnosing a failed update.
Build
The .NET apphost is platform- and architecture-specific, so there is no single binary that serves both a Windows and a Linux client — each target needs its own publish. All targets cross-publish from any host, so one machine produces the whole set.
# From FishMMO-Patcher/ — publishes win-x64 and linux-x64, the two targets Unity
# builds a standalone client for.
./publish-updater.sh
./publish-updater.sh --all # every RID in Updater.csproj
./publish-updater.sh linux-arm64 # an explicit list
publish-updater.ps1 is the same script for Windows hosts.
Under the hood that is one dotnet publish per RID:
dotnet publish Updater/Updater.csproj -c Release -r win-x64
dotnet publish Updater/Updater.csproj -c Release -r linux-x64
No --self-contained / -p:PublishSingleFile flags are needed: Updater.csproj
applies the shipping shape (self-contained, single-file, compressed, invariant
globalization) automatically whenever a RuntimeIdentifier is set. A plain
dotnet build Updater.sln -c Release still produces a framework-dependent build for
local development.
Output lands in Updater/bin/Release/net8.0/<rid>/publish/Updater[.exe].
Self-contained is a requirement, not an optimisation. The updater runs on player machines with no .NET runtime installed, at the one moment the client cannot report an error — the launcher has already shut itself down. A framework-dependent updater fails there with a bare "you must install .NET" on a stdout nobody is reading.
Portable RIDs only. A distro-specific RID — an Arch/CachyOS SDK reports its own as
arch-x64 — has no runtime pack on nuget.org and would pin the output to that distro.
linux-x64 runs on Arch and every other glibc distro precisely because it is not
distro-specific.
Shipping
The FishMMO Dashboard does this for you: a Client build copies the matching per-RID
publish output into the build root (and publishes it on demand if it is missing or
older than the Updater sources). See
BuildExecutor.CopyUpdaterToBuild in the Unity project.
Shipping by hand: put the binary alongside the client executable, plus the Patches/
directory populated by the patch generator.
Flow Diagram
flowchart TD
Launcher[FishMMO Launcher] -->|spawns with args, then exits| U[Updater.Main]
U --> Args[Parse args:<br/>-version / -latestversion / -pid / -exe]
Args --> Shutdown[Shut down launcher PID]
Shutdown --> Graceful{"Graceful request delivered?<br/>(CloseMainWindow / SIGTERM)"}
Graceful -- "exited in 10s" --> Settle[Settle 500ms]
Graceful -- "no, or timed out" --> ForceKill["Kill() + wait 5s"]
ForceKill --> Settle
Settle --> Same{"-version == -latestversion?"}
Same -- yes --> Restart
Same -- no --> Find{"Patches/from-to.zip exists?"}
Find -- no --> Restart
Find -- yes --> Load[Open archive + manifest.json]
Load --> Backup[Take .bak for each affected file]
Backup --> Apply{Apply changes}
Apply -->|New files| WriteNew[Stream from ZIP - parallel]
Apply -->|Modified files| Diff[Apply binary diff<br/>verify pre- and post-hash]
Apply -->|Deletions| Delete[Remove files]
WriteNew --> Move[Atomic move .new to target]
Diff --> Move
Delete --> Move
Move --> OK{All steps OK?}
OK -- yes --> Cleanup[Remove .bak / temp files<br/>delete consumed archive]
OK -- no --> Rollback[Restore from .bak<br/>keep archive for retry]
Cleanup --> Restart["Re-kill PID, start -exe (optional)"]
Rollback --> Restart
Restart --> Exit0[Exit 0]
Both outcomes converge on the same exit. The updater never signals failure through its exit code — see Failure semantics.