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.
| Date uploaded | a month ago |
| Version | 0.2.0 |
| Download link | CeruleanCutlass-Cloudward-0.2.0.zip |
| Downloads | 62 |
| Dependency string | CeruleanCutlass-Cloudward-0.2.0 |
This mod requires the following mods to function
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.10README
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
-
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.
-
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.)
-
Open that file and set two keys in the
[Sync]section βEnableandMountPath. Example:[Sync] Enable = true MountPath = Z:/mnt/nas/outwardMountPath 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/outwardyou'd writeMountPath = Z:/mnt/nas/outward. On native Windows, use a normal path or UNC, e.g.MountPath = Z:\OutwardSyncorMountPath = \\NAS\OutwardSync. Use the same shared folder (and the same Steam account) on every device β they all resolve to oneSaveGames/<SteamID>folder on the share. -
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
syncmarkercommand once (writesyncmarkerintoBepInEx/config/Cloudward_cmd.txtwhile the game runs), ortouch <mount>/.outward-sync-rootby 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 ondocs/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 ofBepInEx/pluginsbefore 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.overlaytravel. Everything else in your.cfgfiles β includingMountPath,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;payloadrollbackputs it back. - Emergency stop that doesn't need the game: create an empty file called
.cloudward-disablein 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.
CHANGELOG
Cloudward changelog
0.2.0 β 2026-08-28
-
You can now see the launch sync working. When the boot pull takes more than a moment (a first sync, or a big share over a slow connection), a small progress bar and status line appear at the bottom of the main menu β "checking shareβ¦", "syncing savesβ¦ 12/44", then a brief "sync complete β pulled 40" β and disappear on their own. A fast sync shows nothing; there is no setting. Previously a multi-minute first sync was indistinguishable from a hang without reading the log.
-
Boot sync can no longer fail in total silence (V-SILENTBOOT, found live on a device that never pulled the share's characters). The boot pass now logs a synchronous
boot reconcile queued:line before any mount I/O, aboot reconcile done: pulled=β¦ pushed=β¦summary on completion (including the previously-silent save-in-progress skip), and apatch ledger: applied=2/2line proving the Harmony hooks bound. A stall watchdog warns once when a background sync op runs past 60 seconds β the signature of a hung network mount, which blocks forever without throwing and used to silently starve every heartbeat for the rest of the session. -
A misconfigured share that resolves to a local folder now names itself (V-SHADOWSHARE). A one-shot boot probe reads the game process's own mount table and warns if
[Sync] MountPathresolves to a local filesystem instead of a real network share, instead of silently syncing against an empty private replica forever. -
The save-set pin no longer engages at every main menu (V-MENUPIN). It was reading a local-player UI slot count that is true from the moment the main menu itself is shown, not from an actual character being loaded β so the boot pull deferred on every single launch, on every device, since the pin shipped. Removed in favour of the three real signals (save in progress, a load sequence running, any character actually in use), which already cover the wedge this pin exists to prevent.
-
The Thunderstore package now actually ships the preloader patcher.
Cloudward.Preloadhas to live inBepInEx/patchers/: it is what applies an adopted payload, and it can only run before the plugins load. It was never included in the Thunderstore package at all, so for anyone who installed Cloudward that way the payload tier could sync but never apply. Manual installs and the hand-off bundle already shipped it. -
Fixed: the
overlays/folder was about to be flattened by r2modman, which would have left the config tier with no overlays to read. Same packaging cause as the Beastwhispering fix; 0.1.1 predates the folder, so no released version was affected. -
Also since 0.1.1: the kit versioning contract (
KitContracthandshake,COMPAT_SINCE,[BepInDependency]version floors), a wave of payload-integrity and reliability fixes (locked+gated boot repair, quit-time publish budget, stale-receipt generation checks, share-tree crash windows, late-mount recovery, join-race protection), clearer failure logging throughout (say when a decision was downgraded; several silent failures made loud), a single share-temp-name/path-denormaliser instead of three separate ones, and the Forge shell / dev-tooling work this mod's live-testing leaned on all session.
Payload sync (mod folders + mod config)
- New
[Payload]tier: sync your mod folders and mod configuration across devices, alongside saves. Off by default ([Payload] Enable = false) β it rewritesBepInEx/plugins, so it has to be a decision rather than a side effect of turning save sync on. - Change detection is free in the steady state. A directory walk produces a size+mtime signature;
if it is unchanged, the tier's content fingerprint is reused and zero bytes are hashed. On a
miss, only the files whose stats moved are re-hashed. mtime is a cache key only β content SHA256
is always the authority, because
cp -a,rsync -aand installers all preserve or reset mtimes in ways that lie. - Adopting a payload never touches the running session. BepInEx loads plugin assemblies at
process start and reads each plugin's
.cfgin its constructor, so an incoming payload is fetched intoBepInEx/cloudward-staged/and applied by a new preloader patcher (BepInEx/patchers/Cloudward.Preload.dll) at the next launch, before the chainloader runs. The patcher verifies every staged file's SHA256 before touching anything: a truncated network fetch discards the staging tree and leaves the install untouched, rather than producing a half-updated plugins folder that loads no mods at all. - The config tier moves values, not files, and only the keys named in
plugins/Cloudward/overlays/*.cfg.overlayβ the same allowlistscripts/sync-config.pyuses. Everything else in your.cfgfiles is host-local and its bytes are never rewritten, soMountPath,DeviceName, keybinds, resolution and joystick GUIDs cannot travel. A no-op merge is byte-identical, enforced in code. - The first boot against a new share reports what it would do and changes nothing.
- Configs, saves, logs, pet files, registries and dev command channels can never ride the plugins
tier β that exclusion is compiled in, not a config setting, and
ExtraExcludescan only add to it. - Divergence (both sides changed) pauses by default rather than guessing: unlike a save fork there is no newest-wins heuristic worth automating when a wrong pick installs the wrong code.
- Backups before every replace, on the share and locally;
payloadrollbackrestores. - Kill switch that works without launching the game: create
.cloudward-disablein the game root. - Refuses to run on an IL2CPP install, and refuses to adopt a payload from a device whose BepInEx loader differs from this one's.
- New verbs:
payloadstatus,payloadscan,payloadpush,payloadstage,payloadresolve,payloadrollback,payloadapply. - Save-tier fixes carried along: the device-local state file is now written temp-then-swap (a second background worker now exists in the process), and a share set up payload-first is recognised as real data by the mount-marker recovery check.
0.1.0 β initial release
- Cross-device save sync via a mounted/shared directory: always-local writes, boot-time reconcile (pull), play/quit push, offline-first.
- Fork detection via a share-side generation ledger; a genuine divergence pauses instead of
auto-merging or auto-discarding (
syncresolve local|share|both). - Marker-file mount guard (an unmounted mountpoint looks like an empty directory otherwise).
- Crash-safe heartbeat session lock β a stale lock is reclaimable by any device, and a device's own crashed lock reclaims instantly on relaunch.
- Verbs:
syncstatus,syncnow,syncresolve,synclock,syncmarker,selftest. - Alpha β design complete, compute layer unit-tested (
tests/Cloudward.Tests), not yet live-verified on a real multi-device session. See the README's known-limitations note.