CeruleanCutlass-Cloudward icon

Cloudward

Sync your Outward saves across devices via a mounted/shared folder: boot-time reconcile, offline-first play, fork detection/resolution, crash-safe session lock. Verified across real devices; some offline/crash-recovery edge cases still being staged.

Last updated 3 weeks ago
Total downloads 178
Total rating 0 
Categories Utility Misc
Dependency string CeruleanCutlass-Cloudward-0.2.0
Dependants 0 other packages depend on this package

This mod requires the following mods to function

BepInEx-BepInExPack_Outward-5.4.19 icon
BepInEx-BepInExPack_Outward

BepInEx pack for Outward.

Preferred version: 5.4.19
CeruleanCutlass-ForgeKit-0.4.10 icon
CeruleanCutlass-ForgeKit

Dependency-free dev-tooling for Outward BepInEx mods: file-driven dev command loop, self-test harness, on-screen toasts, player-ready lifecycle wait, embedded/override table loaders, and a shared dev-verb pack (movement/combat/skill/status probes).

Preferred version: 0.4.10

README

Cloudward — sync your saves across devices

📖 Full documentation: Cloudward wiki page

Actively in use across real devices. The compute layer (mount guard, fork detection, lock/lease, reconciliation) is fully unit-tested, and the full sync loop — boot-time reconcile, offline play, clean two-device handoff, and both paused and auto fork resolution — has been live-verified across real physical machines over a real NAS share, including three bugs found, fixed, and re-verified in that process. Still being staged: some offline/reconnect edge cases and crash-recovery scenarios that need a human at the keyboard to exercise. It is inert by default (Enable=false) — installing it changes nothing until you turn it on. Back up your SaveGames/ folder before your first real sync.

Outward has no built-in way to carry a save between two computers — Steam Cloud is deliberately unsupported for it. Cloudward fills that gap: point it at a folder mounted on every device you play on (a NAS share, a Syncthing folder, a mapped drive) and it keeps your character in sync.

Requires: BepInEx 5 (Outward's Mono branch — see Compatibility) and ForgeKit. No SideLoader.

Installing

Install from Thunderstore and the dependency comes with it. Installing by hand: drop ForgeKit/ and Cloudward/ side by side into BepInEx/plugins/. If BepInEx logs a dependency error and refuses to load Cloudward, ForgeKit is missing.

How it works

The game always writes to its own local save folder — Cloudward never redirects that. The mount you point it at is a synced replica, not the source of truth:

  • At boot, Cloudward reconciles: it pulls in anything newer on the share before the character list loads, so a character you played on another device shows up here.
  • While you play, completed save snapshots push out to the share once you're online and holding the sync lock.
  • Offline just works. No mount, no marker, or another device holding the lock — Cloudward plays entirely from local and catches up next time it can reach the share.

A marker file in the mount root is how Cloudward tells "actually mounted" from "empty directory because the share isn't connected right now" — an unmounted mountpoint looks exactly like an empty folder to your OS, so without the marker a sync could silently start writing to local disk instead of the share. Cloudward creates the marker itself on first run when it can positively confirm the folder is a real mount — the share already holds save data, or the platform probe sees it in the mount table (native Linux /proc/mounts, or a Windows network drive / UNC path). It remembers doing so per mount path, so a later launch with the share disconnected is correctly read as "offline", not re-marked onto the placeholder. Under Proton (Linux / Steam Deck) an empty first-run share can never be confirmed — the game-side process only sees Wine drive paths, so the probe can't reach the mount table. That's expected: Cloudward stays offline, logs the instruction, and you run the syncmarker command once (or touch the marker file) to bless the share; after that everything is automatic. Set AutoCreateMarker=false for strict manual setup, where syncmarker is always required.

A session lock (a small heartbeat file on the share) makes sure only one device pushes at a time. It's crash-safe: a lock whose heartbeat goes stale (the device crashed, lost power, whatever) is reclaimed automatically — nothing can wedge your saves.

If two devices genuinely diverge

If the same character was played offline on two devices before either could sync, Cloudward resolves it automatically per the [Sync] ForkResolution setting — and always backs up the version it doesn't keep to .cloudward-forks/, so nothing is ever lost:

  • NewestWins (default) — keep whichever device has the most recent in-game save.
  • ThisDevice / OtherDevice — always keep this / the other device's version.
  • KeepBoth — keep this device's version live and preserve the other (v1: a restorable backup; a true second save slot is planned for v2).
  • AskMe — don't guess; wait for you to run syncresolve local|share|both.

syncresolve is always available as a manual override.

Setup

  1. Mount the same shared folder on every device you play Outward on (NFS/SMB/autofs on Linux or Deck; a mapped drive or UNC path on Windows; a local Syncthing folder also works). Low-latency is best — a LAN share or local sync folder, not something with multi-second round trips.

  2. Launch Outward once so Cloudward generates its config file, then quit. The file is:

    BepInEx/config/cobalt.cloudward.cfg
    

    (Outward and BepInEx rewrite this file when the game exits, so edit it only while the game is closed — changes made while it's running are overwritten on quit.)

  3. Open that file and set two keys in the [Sync] section — Enable and MountPath. Example:

    [Sync]
    Enable = true
    MountPath = Z:/mnt/nas/outward
    

    MountPath format: Outward runs under Proton on Linux and the Steam Deck, so the path is a Wine drive path — Z: maps to the filesystem root /. For a share mounted at /mnt/nas/outward you'd write MountPath = Z:/mnt/nas/outward. On native Windows, use a normal path or UNC, e.g. MountPath = Z:\OutwardSync or MountPath = \\NAS\OutwardSync. Use the same shared folder (and the same Steam account) on every device → they all resolve to one SaveGames/<SteamID> folder on the share.

  4. Relaunch. On native Windows (network drive / UNC) or where the share already holds data, Cloudward auto-creates the mount marker on first run and you're done. On Proton (Linux / Steam Deck), an empty first-run share can't be auto-confirmed — the log will say so and stay offline; run the syncmarker command once (write syncmarker into BepInEx/config/Cloudward_cmd.txt while the game runs), or touch <mount>/.outward-sync-root by hand, and every later launch is automatic.

To choose how conflicts resolve, set [Sync] ForkResolution (see the settings table below) — e.g. ForkResolution = ThisDevice. The default, NewestWins, is usually what you want.

Settings

BepInEx/config/cobalt.cloudward.cfg, created on first launch:

Setting Default What it does
[Sync] Enable false Master switch. Off = vanilla local-only saves.
MountPath "" Root of the shared/mounted folder to sync through. Empty = inert.
MarkerFileName .outward-sync-root Sentinel file that marks the share as actually mounted.
AutoCreateMarker true Auto-create the marker when the share is positively confirmable: it already holds real data, or the platform probe sees a real mount (native-Linux /proc/mounts, Windows network drive / UNC). An ambiguous empty dir stays offline with a one-time log hint instead of being stamped — under Proton the probe can't see the mount table, so an empty first-run share always takes the one-time syncmarker path. Created once per mount path and remembered, so an absent marker later reads as "disconnected → offline", never re-stamped onto a placeholder. false = strict manual mode (syncmarker always required).
OnMountDown FallbackLocal What to do if the mount isn't live at launch. FallbackLocal = play local + warn; RefuseAndLog = same, but log louder (useful for headless/dedicated sessions).
OnLockHeld FallbackLocal What to do about pushing when another device holds a live lock (pulls always happen regardless). FallbackLocal = defer pushes and accumulate locally until it frees (safe default); ForceTake = push anyway — only if you're certain the other device is dead.
ForkResolution NewestWins How a diverged character (played offline on two devices) is auto-resolved: NewestWins = keep the most recent in-game save; ThisDevice / OtherDevice = always keep this / the other device's version; KeepBoth = keep this device's live and preserve the other (v1: a restorable backup); AskMe = wait for a manual syncresolve. The version not kept is always backed up to .cloudward-forks/.
[Lock] HeartbeatSeconds 30 How often the held lock's heartbeat refreshes (and the mount gets re-checked).
StaleSeconds 180 A lock heartbeat older than this is dead and reclaimable by any device — crash recovery.
DeviceName your hostname This device's name in the lock file. Must be unique per device — own-device locks reclaim instantly, so two devices sharing a name would never block each other.

Syncing your mods and settings too ([Payload], optional, off by default)

Cloudward can also carry your mod folders and mod configuration between the same devices, so a character doesn't arrive on a machine running last month's build. It is off by default: this rewrites BepInEx/plugins, so it should be a decision, not something that happens because you turned save sync on. The first boot after you enable it only reports what it would do.

This tier is NOT live-verified. The "actively in use across real devices" line at the top of this README describes the save-sync tier only. [Payload] shipped in 0.2.0 and every item on docs/cloudward-payload-testplan.md (P1–P16) is still open — including the full round trip (P3), rollback recovery (P7), the corrupted-file refusal (P9), the privacy gate (P10) and .cloudward-disable (P12). Everything below describes what it is built to do, not what has been observed to work. Have a backup of BepInEx/plugins before you turn it on.

How it behaves, in short:

  • It only does work when something actually changed. Each launch it walks the tree and compares a size+mtime signature; unchanged means zero files are hashed and nothing is copied.
  • Incoming updates apply at the NEXT launch, not this one. BepInEx loads mods at startup, so a running game cannot swap them. Cloudward downloads the update, verifies every file's checksum, and a small preloader component (BepInEx/patchers/Cloudward.Preload.dll) installs it just before mods load next time you start the game. If anything fails verification, the update is discarded and your install is left exactly as it was.
  • Config sync is an allowlist, not a file copy. Only the keys named in BepInEx/plugins/Cloudward/overlays/*.cfg.overlay travel. Everything else in your .cfg files — including MountPath, DeviceName, your keybinds, resolution and controller bindings — is host-local and its bytes are never rewritten.
  • Your saves, logs, and per-character mod data never leave via this tier, whatever else is in those folders.
  • If both devices changed, it stops and waits for you (payloadresolve) rather than guessing. The replaced version is always backed up first; payloadrollback puts it back.
  • Emergency stop that doesn't need the game: create an empty file called .cloudward-disable in your Outward game folder and all payload activity halts, including the part that runs at startup.
Setting Default What it does
[Payload] Enable false Master switch for mod + config sync. Needs [Sync] Enable and a MountPath as well.
SyncPlugins true Sync the BepInEx/plugins tree (whole folders, including the data files mods ship beside their DLL).
SyncConfig true Sync the config keys named in plugins/Cloudward/overlays/*.cfg.overlay, and only those.
Role Both Both = publish and adopt. Publisher = never adopt. Subscriber = never write to the share (use this on someone else's machine).
ApplyMode Auto Auto = a downloaded update installs itself at the next launch. StageOnly = download it and wait for payloadapply.
OnConflict Skip Both sides changed. Skip = touch nothing and wait for payloadresolve; PreferLocal / PreferShare decide automatically at boot.
PushAtQuit false Also publish on quit. Off by default: quitting has a 15-second budget and a plugins folder is large. Publishing normally happens at boot.
ExtraExcludes "" Extra ;-separated patterns to keep off the share. Additive only — the built-in exclusions can't be switched off.
MaxPayloadMegabytes 2048 Refuse to publish a tier bigger than this (a sudden jump almost always means a mis-set folder).
KeepBackups 3 How many pre-replace backups to keep per tier.
[Payload]
## Master switch for mod + config sync. Requires [Sync] Enable and MountPath.
Enable = false
SyncPlugins = true
SyncConfig = true
## Both | Publisher | Subscriber
Role = Both
## Auto | StageOnly
ApplyMode = Auto
## Skip | PreferLocal | PreferShare
OnConflict = Skip
PushAtQuit = false
ExtraExcludes =
MaxPayloadMegabytes = 2048
KeepBackups = 3

Example configuration

BepInEx/config/cobalt.cloudward.cfg — created on first launch. Excerpt (defaults; set at least Enable and MountPath to turn it on):

[Sync]
## Master switch. Off = vanilla local-only saves.
Enable = false
## Root of the shared/mounted folder to sync through. Empty = inert.
MountPath =
## Sentinel file that marks the share as actually mounted.
MarkerFileName = .outward-sync-root
AutoCreateMarker = true
## FallbackLocal | RefuseAndLog
OnMountDown = FallbackLocal
## FallbackLocal | ForceTake
OnLockHeld = FallbackLocal
## NewestWins | ThisDevice | OtherDevice | KeepBoth | AskMe
ForkResolution = NewestWins

[Lock]
HeartbeatSeconds = 30
StaleSeconds = 180
## This device's name in the lock file; defaults to your hostname. Must be unique per device.
DeviceName = <your hostname>

The dev command channel is BepInEx/config/Cloudward_cmd.txt (see the verb table below). Cloudward ships no config-override data tables.

Without any settings changes

Everything is also driven by writing a line into BepInEx/config/Cloudward_cmd.txt — it runs on the next poll, even while the game is paused.

Verb What it does
syncstatus Dump sync state: mode, local/share paths, lock holder, pending forks.
syncnow Force a reconcile + push right now (pushes only — a pull needs the next launch).
syncresolve <local|share|both> [uid] Resolve a paused fork.
payloadstatus Dump mod/config sync state: tiers, generations, what's staged, conflicts, backups.
payloadscan [plugins|config] Re-check from cold, ignoring the size+mtime shortcut.
payloadpush [plugins|config] Publish this device's mods/config now.
payloadstage [plugins|config] Download the share's version now, ready for the next launch.
payloadresolve <local|share> [tier] Resolve a mod/config divergence.
payloadrollback <plugins|config> [n] Restore the share from a backup (0 = newest).
payloadapply Release an update held by ApplyMode = StageOnly.
synclock / synclock release Show the current lock holder / push-and-release your own.
syncmarker Manually stamp the mount-liveness marker into MountPath (optional — auto-created unless AutoCreateMarker=false).
selftest Sanity-check the install; look for [SELFTEST] … DONE in the log.

Compatibility

Outward must be on its Mono Steam beta branch, not the default IL2CPP build (Properties → Betas → select mono). If the game runs but no BepInEx mods load and there's no crash log, this is almost always why.

License

Apache License 2.0 — see LICENSE. You may use, modify, and redistribute this mod (including in commercial mods) provided you keep the copyright/license notice; see the license text for the full terms.