using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.Security;
using System.Security.Permissions;
using System.Threading;
using System.Threading.Tasks;
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using Cloudward.Core;
using Cloudward.Lock;
using ForgeKit;
using HarmonyLib;
using UnityEngine;
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
[assembly: AssemblyCompany("Cloudward")]
[assembly: AssemblyConfiguration("Release")]
[assembly: AssemblyFileVersion("0.1.0.0")]
[assembly: AssemblyInformationalVersion("0.1.0+d3fbef8726f47d3953b79ef11c8e75d2f5805c9a")]
[assembly: AssemblyProduct("Cloudward")]
[assembly: AssemblyTitle("Cloudward")]
[assembly: AssemblyMetadata("BuildStamp", "d3fbef8 2026-07-24")]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("0.1.0.0")]
[module: UnverifiableCode]
namespace Cloudward
{
[BepInPlugin("cobalt.cloudward", "Cloudward", "0.1.1")]
[BepInDependency(/*Could not decode attribute arguments.*/)]
public class Plugin : BaseUnityPlugin
{
public const string GUID = "cobalt.cloudward";
public const string NAME = "Cloudward";
public const string VERSION = "0.1.1";
internal static ManualLogSource Log;
internal static SyncCoordinator Coordinator;
internal static int OwnPid;
public static ConfigEntry<bool> Enable;
public static ConfigEntry<string> MountPath;
public static ConfigEntry<string> MarkerFileName;
public static ConfigEntry<bool> AutoCreateMarker;
public static ConfigEntry<MountDownPolicy> OnMountDown;
public static ConfigEntry<LockHeldPolicy> OnLockHeld;
public static ConfigEntry<ForkResolution> ForkPolicy;
public static ConfigEntry<int> HeartbeatSeconds;
public static ConfigEntry<int> StaleSeconds;
public static ConfigEntry<string> DeviceName;
private CommandRegistry _commands;
private VerbHost _verbs;
private CommandChannel _channel;
private float _lastHeartbeat;
internal void Awake()
{
//IL_01a3: Unknown result type (might be due to invalid IL or missing references)
//IL_01ad: Expected O, but got Unknown
//IL_01b2: Unknown result type (might be due to invalid IL or missing references)
Log = ((BaseUnityPlugin)this).Logger;
try
{
OwnPid = Process.GetCurrentProcess().Id;
}
catch
{
OwnPid = 0;
}
Enable = ((BaseUnityPlugin)this).Config.Bind<bool>("Sync", "Enable", false, "Master switch. When false, saves stay in the local game folder (vanilla behavior).");
MountPath = ((BaseUnityPlugin)this).Config.Bind<string>("Sync", "MountPath", "", "Root of the mounted/shared directory to sync saves through (e.g. /mnt/nas/outward or Z:\\outward). Empty = inert. The game's SaveGames/<SteamID> tail is preserved under it, so the same Steam account on every device lands in one shared folder.");
MarkerFileName = ((BaseUnityPlugin)this).Config.Bind<string>("Sync", "MarkerFileName", ".outward-sync-root", "Sentinel file that must exist in MountPath for the share to count as mounted. An unmounted mountpoint is an empty dir with no marker, so this stops writes landing on local disk. With AutoCreateMarker=true (default) Cloudward creates it for you; otherwise stamp it once with the 'syncmarker' verb (or touch it by hand).");
AutoCreateMarker = ((BaseUnityPlugin)this).Config.Bind<bool>("Sync", "AutoCreateMarker", true, "Create the mount marker automatically so config-only setup works (just set MountPath — no 'syncmarker' step). On the FIRST run for a mount path the marker is created and remembered; on a later launch when the mount is down (marker absent because the share isn't mounted) Cloudward reads that as 'disconnected → play offline' and does NOT re-stamp the placeholder. A share that already holds real data (a SaveGames/ folder or a ledger) always gets its marker restored. Set false for the strict old behavior: the marker must already exist ('syncmarker' required).");
OnMountDown = ((BaseUnityPlugin)this).Config.Bind<MountDownPolicy>("Sync", "OnMountDown", MountDownPolicy.FallbackLocal, "What to do if the mount isn't live at launch. FallbackLocal = use local saves + warn; RefuseAndLog = same, but log an error (louder for headless sessions).");
OnLockHeld = ((BaseUnityPlugin)this).Config.Bind<LockHeldPolicy>("Sync", "OnLockHeld", (LockHeldPolicy)0, "What to do about PUSHING when another device holds a LIVE lock (pulls always happen). FallbackLocal = defer pushes, accumulate locally, sync when it frees (safe default); ForceTake = push anyway (only if you know the other device is dead). ReadOnly behaves like FallbackLocal here (there is no redirect in the always-local model).");
ForkPolicy = ((BaseUnityPlugin)this).Config.Bind<ForkResolution>("Sync", "ForkResolution", (ForkResolution)0, "When the SAME character diverged on two devices (both played it offline from a common point), how to resolve it automatically. The version that isn't kept live is always backed up to .cloudward-forks/ (never silently discarded). NewestWins = keep whichever device has the most recent IN-GAME save (default — no prompt, just works); ThisDevice = always keep this device's version; OtherDevice = always take the other device's version; KeepBoth = keep this device's live and preserve the other (v1: a restorable backup; a true second save slot is planned for v2); AskMe = leave it untouched and wait for a manual 'syncresolve local|share|both' decision.");
HeartbeatSeconds = ((BaseUnityPlugin)this).Config.Bind<int>("Lock", "HeartbeatSeconds", 30, "How often the held lock's heartbeat is refreshed (and the mount re-checked).");
StaleSeconds = ((BaseUnityPlugin)this).Config.Bind<int>("Lock", "StaleSeconds", 180, "A lock whose heartbeat is older than this is considered dead (crash recovery) and is reclaimable by any device. Should be several heartbeats.");
DeviceName = ((BaseUnityPlugin)this).Config.Bind<string>("Lock", "DeviceName", SafeMachineName(), "This device's name in the lock file. Must be UNIQUE per device — own-device locks are reclaimed instantly, so two devices sharing a name would never block each other.");
Coordinator = new SyncCoordinator(Log);
RegisterVerbs();
_channel = new CommandChannel("Cloudward_cmd.txt", Log, _commands, 0.5f, true, false);
new Harmony("cobalt.cloudward").PatchAll();
Log.LogMessage((object)string.Format("{0} {1} loaded (Enable={2}, device={3}).", "Cloudward", "0.1.1", Enable.Value, DeviceName.Value));
}
internal void Update()
{
_channel.Tick();
Coordinator?.PumpMainThread();
int num = Mathf.Max(1, HeartbeatSeconds.Value);
if (Time.unscaledTime - _lastHeartbeat >= (float)num)
{
_lastHeartbeat = Time.unscaledTime;
Coordinator?.Heartbeat();
}
}
private static string SafeMachineName()
{
try
{
return Environment.MachineName;
}
catch
{
return "device";
}
}
private void RegisterVerbs()
{
//IL_0006: Unknown result type (might be due to invalid IL or missing references)
//IL_0010: Expected O, but got Unknown
//IL_003b: Unknown result type (might be due to invalid IL or missing references)
//IL_0045: Expected O, but got Unknown
_commands = new CommandRegistry(Log);
_verbs = new VerbHost(_commands, Log, (Func<Character>)(() => (Character)null));
_verbs.Register("selftest", "Run the Cloudward self-test ([SELFTEST] PASS/FAIL ... DONE).", (Action<VerbContext>)delegate
{
SelfTest();
}, "[Cloudward]", false, true, false, (string)null);
_verbs.Register("syncstatus", "Dump sync state: mode, local/share paths, lock holder, pending forks.", (Action<VerbContext>)delegate
{
Status();
}, "[Cloudward]", false, true, false, (string)null);
_verbs.Register("syncnow", "Force a reconcile + push now (push-only; pulls apply at next launch).", (Action<VerbContext>)delegate
{
Coordinator?.SyncNow();
}, "[Cloudward]", false, true, false, (string)null);
_verbs.Register("syncresolve", "Resolve a paused fork: 'syncresolve <local|share|both> [uid]'.", (Action<VerbContext>)delegate(VerbContext ctx)
{
Coordinator?.ResolveFork(ctx.Arg(1), ctx.Arg(2));
}, "[Cloudward]", false, true, false, (string)null);
_verbs.Register("synclock", "Show the current lock ('synclock'); push+release ours ('synclock release').", (Action<VerbContext>)delegate(VerbContext ctx)
{
if (ctx.Arg(1) == "release")
{
Coordinator?.PushAndReleaseAtQuit();
Log.LogMessage((object)"[Cloudward] lock released on request.");
}
else
{
Log.LogMessage((object)("[Cloudward] lock holder: " + (Coordinator?.CurrentHolder() ?? "(none/unreadable)") + "."));
}
}, "[Cloudward]", false, true, false, (string)null);
_verbs.Register("syncmarker", "Manually stamp the mount-liveness marker into MountPath (not required with AutoCreateMarker=true).", (Action<VerbContext>)delegate
{
StampMarker();
}, "[Cloudward]", false, true, false, (string)null);
}
private void Status()
{
SyncCoordinator coordinator = Coordinator;
Log.LogMessage((object)$"[Cloudward] mode={coordinator?.Mode} holdingLock={coordinator?.HoldingLock}");
Log.LogMessage((object)("[Cloudward] local (game writes here) = " + (coordinator?.LocalRoot ?? SaveManager.GetSavePath())));
Log.LogMessage((object)("[Cloudward] share replica = " + (coordinator?.TargetPath ?? "(none)")));
string text = (MountPath.Value ?? "").Trim();
if (text.Length > 0)
{
Log.LogMessage((object)string.Format("[Cloudward] mount '{0}' live={1} holder={2}", text, coordinator?.LastMountLive, coordinator?.CurrentHolder() ?? "(none)"));
}
IReadOnlyList<string> readOnlyList = coordinator?.PendingForks();
if (readOnlyList != null && readOnlyList.Count > 0)
{
Log.LogMessage((object)("[Cloudward] PENDING FORKS: " + string.Join(", ", readOnlyList) + " — run 'syncresolve'."));
}
}
private void StampMarker()
{
string text = (MountPath.Value ?? "").Trim();
if (text.Length == 0)
{
Log.LogWarning((object)"[Cloudward] set MountPath first.");
return;
}
try
{
Directory.CreateDirectory(text);
string text2 = Path.Combine(text, MarkerFileName.Value);
File.WriteAllText(text2, "Cloudward mount marker — do not delete.\n");
Log.LogMessage((object)("[Cloudward] wrote marker " + text2 + ". This share is now recognized as mounted."));
}
catch (Exception ex)
{
Log.LogError((object)("[Cloudward] could not write marker into '" + text + "' (" + ex.GetType().Name + ": " + ex.Message + ")."));
}
}
private void SelfTest()
{
//IL_0005: Unknown result type (might be due to invalid IL or missing references)
//IL_000b: Expected O, but got Unknown
//IL_00c7: Unknown result type (might be due to invalid IL or missing references)
//IL_00eb: Expected O, but got Unknown
//IL_00e6: Unknown result type (might be due to invalid IL or missing references)
//IL_00ec: Invalid comparison between Unknown and I4
SelfTestHarness val = new SelfTestHarness(Log);
val.Begin("Cloudward");
val.Check("logger wired", Log != null);
val.Check("coordinator built", Coordinator != null);
val.Check("config bound", Enable != null && MountPath != null && OnLockHeld != null);
val.Check("no cross-mod keybind conflicts", !Keybinds.HasConflicts());
val.Check("mount guard: no marker => down", !MountGuard.IsLive(false, true));
val.Check("path rebase keeps account tail", PathRebase.Target("/x/SaveGames/ACCT", "/mnt/nas") == "/mnt/nas/SaveGames/ACCT");
val.Check("own-device lock reclaims", (int)LockLease.Decide(DateTime.UtcNow, new LockStamp(DeviceName.Value, 1, DateTime.UtcNow), DeviceName.Value, OwnPid, StaleSeconds.Value, (LockHeldPolicy)0) == 0);
val.Done();
}
}
public enum MountDownPolicy
{
FallbackLocal,
RefuseAndLog
}
internal enum SyncMode
{
Inactive,
Offline,
Online
}
internal sealed class SyncCoordinator
{
private readonly ManualLogSource _log;
private bool _bootDone;
private volatile SyncMode _mode;
private volatile bool _holdingLock;
private volatile bool _needsRefresh;
private volatile bool _lastMountLive;
private int _busy;
private SessionLock _lock;
private LocalSyncState _state;
public SyncMode Mode => _mode;
public bool HoldingLock => _holdingLock;
public bool LastMountLive => _lastMountLive;
public string LocalRoot { get; private set; }
public string TargetPath { get; private set; }
private string LedgerPath => Path.Combine(TargetPath, ".cloudward-ledger");
private string ForksBackupRoot => Path.Combine(TargetPath, ".cloudward-forks");
private static string StatePath => Path.Combine(Paths.ConfigPath, "cloudward_state.txt");
public SyncCoordinator(ManualLogSource log)
{
_log = log;
}
private static string CharDir(string root, string uid)
{
return Path.Combine(root, "Save_" + uid);
}
private bool RunInBackground(string tag, Action work, bool quietIfBusy = false)
{
if (Interlocked.CompareExchange(ref _busy, 1, 0) != 0)
{
if (!quietIfBusy)
{
_log.LogMessage((object)("[CLOUDWARD] a sync is already running — skipping " + tag + "."));
}
return false;
}
Task.Run(delegate
{
try
{
work();
}
catch (Exception arg)
{
_log.LogError((object)$"[CLOUDWARD] {tag} failed: {arg}");
}
finally
{
Interlocked.Exchange(ref _busy, 0);
}
});
return true;
}
public void PumpMainThread()
{
if (!_needsRefresh)
{
return;
}
_needsRefresh = false;
try
{
SaveManager.Instance.Reset();
SaveManager.Instance.RetrieveCharacterSaves();
_log.LogMessage((object)"[CLOUDWARD] character list refreshed after sync.");
}
catch (Exception arg)
{
_log.LogError((object)$"[CLOUDWARD] character-list refresh failed: {arg}");
}
}
public void TryReconcileAtBoot()
{
if (_bootDone)
{
return;
}
_bootDone = true;
if (!Plugin.Enable.Value)
{
_log.LogMessage((object)"[CLOUDWARD] disabled; local saves only.");
_mode = SyncMode.Inactive;
return;
}
string mountRoot = (Plugin.MountPath.Value ?? "").Trim();
if (mountRoot.Length == 0)
{
_log.LogMessage((object)"[CLOUDWARD] set [Sync] MountPath to enable; local saves only.");
_mode = SyncMode.Inactive;
return;
}
LocalRoot = SaveManager.GetSavePath();
_state = LoadState();
_mode = SyncMode.Offline;
RunInBackground("boot reconcile", delegate
{
if (!EnsureMountLive(mountRoot))
{
string text = "[CLOUDWARD] mount not live (marker '" + Plugin.MarkerFileName.Value + "' missing at '" + mountRoot + "'); playing OFFLINE — will sync on reconnect.";
if (Plugin.OnMountDown.Value == MountDownPolicy.RefuseAndLog)
{
_log.LogError((object)text);
}
else
{
_log.LogWarning((object)text);
}
_mode = SyncMode.Offline;
}
else if (!SetupTarget(mountRoot))
{
_mode = SyncMode.Offline;
}
else
{
AcquireLockForPush();
DoReconcile(pullAllowed: true);
_mode = SyncMode.Online;
}
});
}
private bool SetupTarget(string mountRoot)
{
TargetPath = PathRebase.Target(LocalRoot, mountRoot);
try
{
Directory.CreateDirectory(TargetPath);
return true;
}
catch (Exception ex)
{
_log.LogError((object)("[CLOUDWARD] cannot open target '" + TargetPath + "' (" + ex.Message + "); OFFLINE."));
return false;
}
}
private void AcquireLockForPush()
{
//IL_0040: Unknown result type (might be due to invalid IL or missing references)
//IL_0045: Unknown result type (might be due to invalid IL or missing references)
//IL_004a: Unknown result type (might be due to invalid IL or missing references)
//IL_004b: Unknown result type (might be due to invalid IL or missing references)
//IL_0053: Unknown result type (might be due to invalid IL or missing references)
//IL_0059: Invalid comparison between Unknown and I4
_lock = new SessionLock(TargetPath, Plugin.DeviceName.Value, Plugin.OwnPid, _log);
LeaseDecision val = _lock.Decide(DateTime.UtcNow, Plugin.StaleSeconds.Value, Plugin.OnLockHeld.Value);
if ((int)val == 0 || (int)Plugin.OnLockHeld.Value == 2)
{
_lock.Write(DateTime.UtcNow);
_holdingLock = true;
return;
}
_holdingLock = false;
ManualLogSource log = _log;
LockStamp obj = _lock.Read();
log.LogWarning((object)("[CLOUDWARD] share in use on '" + (((obj != null) ? obj.Device : null) ?? "another device") + "'; will PULL but defer pushes until it frees (like offline)."));
}
private void DoReconcile(bool pullAllowed)
{
//IL_0036: Unknown result type (might be due to invalid IL or missing references)
//IL_003b: Unknown result type (might be due to invalid IL or missing references)
//IL_003d: Unknown result type (might be due to invalid IL or missing references)
//IL_00be: Unknown result type (might be due to invalid IL or missing references)
//IL_00c4: Invalid comparison between Unknown and I4
//IL_0195: Unknown result type (might be due to invalid IL or missing references)
//IL_019a: Unknown result type (might be due to invalid IL or missing references)
//IL_019c: Unknown result type (might be due to invalid IL or missing references)
//IL_01af: Expected I4, but got Unknown
//IL_00d5: Unknown result type (might be due to invalid IL or missing references)
//IL_0116: Unknown result type (might be due to invalid IL or missing references)
//IL_0120: Expected O, but got Unknown
//IL_0237: Unknown result type (might be due to invalid IL or missing references)
//IL_0241: Expected O, but got Unknown
if (SaveManager.Instance.SaveInProgress)
{
return;
}
SyncLedger val = LoadLedger();
Dictionary<string, CharTree> dictionary = TreeScanner.Scan(LocalRoot);
Dictionary<string, CharTree> dictionary2 = TreeScanner.Scan(TargetPath);
bool flag = false;
bool flag2 = false;
ForkResolution value = Plugin.ForkPolicy.Value;
bool flag3 = ForkResolve.IsAuto(value);
foreach (string item in dictionary.Keys.Union(dictionary2.Keys).ToList())
{
bool flag4 = _state.GetFork(item) != null;
if (flag4 && !flag3)
{
continue;
}
ReconcilePlan val2 = Reconciler.Plan(item, Tree(dictionary, item), Tree(dictionary2, item), val.Get(item), _state.Get(item));
if ((int)val2.Action == 3)
{
if (flag3)
{
(bool, bool) tuple = AutoResolve(item, val2, dictionary, dictionary2, val, value);
flag |= tuple.Item1;
flag2 |= tuple.Item2;
}
else if (!flag4)
{
_state.SetFork(item, new ForkRecord(val2.LocalHead, val2.ShareHead));
_log.LogError((object)("[CLOUDWARD] " + item + ": FORK — sync paused (local head " + val2.LocalHead + " vs share " + val2.ShareHead + "). Run 'syncresolve local|share|both " + item + "'."));
}
continue;
}
if (flag4)
{
_state.ClearFork(item);
}
SyncAction action = val2.Action;
switch ((int)action)
{
case 1:
if (pullAllowed && ExecPull(item, val2))
{
flag = true;
}
break;
case 2:
if (_holdingLock)
{
flag2 |= ExecPush(item, val2, val);
}
else
{
_log.LogMessage((object)("[CLOUDWARD] " + item + ": local ahead but lock held elsewhere — deferring push."));
}
break;
case 0:
{
LedgerEntry val3 = val.Get(item);
if (val3 != null && val2.NewHead.Length > 0)
{
_state.Set(item, new LocalCharState(val3.Gen, val2.NewHead));
}
break;
}
}
}
SaveState();
if (flag2)
{
SaveLedger(val);
}
if (flag)
{
_needsRefresh = true;
}
}
private (bool pulled, bool ledgerDirty) AutoResolve(string uid, ReconcilePlan plan, Dictionary<string, CharTree> local, Dictionary<string, CharTree> share, SyncLedger ledger, ForkResolution policy)
{
//IL_0011: Unknown result type (might be due to invalid IL or missing references)
//IL_015d: Unknown result type (might be due to invalid IL or missing references)
//IL_00c0: Unknown result type (might be due to invalid IL or missing references)
//IL_00c3: Invalid comparison between Unknown and I4
//IL_010a: Unknown result type (might be due to invalid IL or missing references)
//IL_007a: Unknown result type (might be due to invalid IL or missing references)
//IL_0064: Unknown result type (might be due to invalid IL or missing references)
//IL_006e: Expected O, but got Unknown
CharTree val = Tree(local, uid);
CharTree val2 = Tree(share, uid);
if (ForkResolve.LocalWins(policy, Reconciler.LocalIsNewer(plan.LocalHead, plan.ShareHead)) ?? true)
{
if (!_holdingLock)
{
if (_state.GetFork(uid) == null)
{
_state.SetFork(uid, new ForkRecord(plan.LocalHead, plan.ShareHead));
}
_log.LogMessage((object)$"[CLOUDWARD] {uid}: fork ({policy}, keeping local) — deferring resolve until the lock is held.");
return (pulled: false, ledgerDirty: false);
}
string text = BackupLineage(uid, TargetPath, val2, "share");
PushWins(uid, val, share, ledger);
_state.ClearFork(uid);
if ((int)policy == 3)
{
_log.LogMessage((object)("[CLOUDWARD] " + uid + ": fork resolved (KeepBoth) — local kept live; the other version is preserved at " + text + " (v1: a restorable backup, not yet a separate save slot)."));
}
else
{
_log.LogMessage((object)$"[CLOUDWARD] {uid}: fork auto-resolved ({policy}) — kept LOCAL; other version backed up at {text}.");
}
return (pulled: false, ledgerDirty: true);
}
string arg = BackupLineage(uid, LocalRoot, val, "local");
PullWins(uid, val2, local, ledger);
_state.ClearFork(uid);
_log.LogMessage((object)$"[CLOUDWARD] {uid}: fork auto-resolved ({policy}) — kept SHARE; other version backed up at {arg}.");
return (pulled: true, ledgerDirty: false);
}
public void Heartbeat()
{
if (_mode == SyncMode.Inactive || TargetPath == null)
{
return;
}
RunInBackground("heartbeat", delegate
{
string text = (Plugin.MountPath.Value ?? "").Trim();
bool flag = text.Length > 0 && EnsureMountLive(text);
if (_mode == SyncMode.Online && !flag)
{
_log.LogWarning((object)"[CLOUDWARD] mount went away — playing OFFLINE, will sync on reconnect.");
_mode = SyncMode.Offline;
_holdingLock = false;
}
else if (_mode == SyncMode.Offline && flag)
{
_log.LogMessage((object)"[CLOUDWARD] mount is back — pushing deferred local changes (pulls apply next launch).");
AcquireLockForPush();
_mode = SyncMode.Online;
DoReconcile(pullAllowed: false);
}
else if (_mode == SyncMode.Online)
{
if (!_holdingLock)
{
AcquireLockForPush();
}
if (_holdingLock)
{
_lock?.Write(DateTime.UtcNow);
DoReconcile(pullAllowed: false);
}
}
}, quietIfBusy: true);
}
public void PushAndReleaseAtQuit()
{
try
{
if (_mode == SyncMode.Online && _holdingLock)
{
DoReconcile(pullAllowed: false);
}
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] quit push failed (" + ex.Message + ")."));
}
if (_holdingLock && _lock != null)
{
_lock.ReleaseIfOwn();
_log.LogMessage((object)"[CLOUDWARD] released the lock.");
}
_holdingLock = false;
}
public void ResolveFork(string choice, string uid)
{
if (_state == null)
{
_log.LogWarning((object)"[CLOUDWARD] sync not active.");
return;
}
if (uid == null)
{
List<string> list = _state.PendingForks.ToList();
if (list.Count != 1)
{
_log.LogWarning((object)("[CLOUDWARD] specify a uid — pending forks: " + string.Join(", ", list)));
return;
}
uid = list[0];
}
if (_state.GetFork(uid) == null)
{
_log.LogWarning((object)("[CLOUDWARD] no pending fork for " + uid + "."));
return;
}
if (choice != "local" && choice != "share" && choice != "both")
{
_log.LogWarning((object)"[CLOUDWARD] syncresolve <local|share|both> [uid].");
return;
}
string target = uid;
RunInBackground("resolve " + choice + " " + target, delegate
{
if (TargetPath == null || !EnsureMountLive((Plugin.MountPath.Value ?? "").Trim()))
{
_log.LogWarning((object)"[CLOUDWARD] mount not live; can't resolve now.");
}
else
{
SyncLedger ledger = LoadLedger();
Dictionary<string, CharTree> dictionary = TreeScanner.Scan(LocalRoot);
Dictionary<string, CharTree> dictionary2 = TreeScanner.Scan(TargetPath);
CharTree val = Tree(dictionary, target);
CharTree val2 = Tree(dictionary2, target);
bool flag = false;
switch (choice)
{
case "local":
BackupLineage(target, TargetPath, val2, "share");
PushWins(target, val, dictionary2, ledger);
break;
case "share":
BackupLineage(target, LocalRoot, val, "local");
PullWins(target, val2, dictionary, ledger);
flag = true;
break;
case "both":
{
string text = BackupLineage(target, TargetPath, val2, "share");
PushWins(target, val, dictionary2, ledger);
_log.LogMessage((object)("[CLOUDWARD] 'both': local kept live; the other version is preserved at " + text + " (restore by copying its snapshot folders into a Save_<uid> dir)."));
break;
}
}
_state.ClearFork(target);
SaveState();
SaveLedger(ledger);
if (flag)
{
_needsRefresh = true;
}
_log.LogMessage((object)("[CLOUDWARD] fork on " + target + " resolved: " + choice + "."));
}
});
}
public void SyncNow()
{
if (_mode == SyncMode.Inactive)
{
_log.LogMessage((object)"[CLOUDWARD] inactive.");
return;
}
RunInBackground("syncnow", delegate
{
string mountRoot = (Plugin.MountPath.Value ?? "").Trim();
if (!EnsureMountLive(mountRoot))
{
_log.LogWarning((object)"[CLOUDWARD] mount not live.");
}
else
{
if (_mode == SyncMode.Offline)
{
AcquireLockForPush();
_mode = SyncMode.Online;
}
else if (!_holdingLock)
{
AcquireLockForPush();
}
DoReconcile(pullAllowed: false);
_log.LogMessage((object)"[CLOUDWARD] syncnow done (push-only; pulls apply at next launch).");
}
});
}
private void PushWins(string uid, CharTree local, Dictionary<string, CharTree> share, SyncLedger ledger)
{
//IL_0069: Unknown result type (might be due to invalid IL or missing references)
//IL_0073: Expected O, but got Unknown
//IL_0081: Unknown result type (might be due to invalid IL or missing references)
//IL_008b: Expected O, but got Unknown
LedgerEntry obj = ledger.Get(uid);
int num = ((obj != null) ? obj.Gen : 0) + 1;
SafeCopy(CharDir(LocalRoot, uid), CharDir(TargetPath, uid), MissingList(local, Tree(share, uid)));
SnapshotCopier.PruneTo(CharDir(TargetPath, uid), 15);
ledger.Set(uid, new LedgerEntry(num, local.Head, Plugin.DeviceName.Value));
_state.Set(uid, new LocalCharState(num, local.Head));
}
private void PullWins(string uid, CharTree share, Dictionary<string, CharTree> local, SyncLedger ledger)
{
//IL_00b2: Unknown result type (might be due to invalid IL or missing references)
//IL_00bc: Expected O, but got Unknown
LedgerEntry obj = ledger.Get(uid);
int num = ((obj != null) ? obj.Gen : 0);
CharTree val = Tree(local, uid);
SafeCopy(CharDir(TargetPath, uid), CharDir(LocalRoot, uid), MissingList(share, val));
if (val != null)
{
HashSet<string> shareSet = new HashSet<string>(share.Snapshots, StringComparer.Ordinal);
List<string> list = val.Snapshots.Where((string s) => !shareSet.Contains(s)).ToList();
SnapshotCopier.RemoveSnapshots(CharDir(LocalRoot, uid), (IEnumerable<string>)list);
}
SnapshotCopier.PruneTo(CharDir(LocalRoot, uid), 15);
_state.Set(uid, new LocalCharState(num, share.Head));
}
private string BackupLineage(string uid, string srcRoot, CharTree tree, string side)
{
if (tree.IsEmpty)
{
return "(nothing)";
}
string text = Path.Combine(ForksBackupRoot, uid + "-" + side + "-" + tree.Head);
try
{
SafeCopy(CharDir(srcRoot, uid), text, tree.Snapshots);
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] backup of " + side + " lineage failed (" + ex.Message + ")."));
}
return text;
}
private bool ExecPull(string uid, ReconcilePlan plan)
{
//IL_004a: Unknown result type (might be due to invalid IL or missing references)
//IL_0054: Expected O, but got Unknown
try
{
SafeCopy(CharDir(TargetPath, uid), CharDir(LocalRoot, uid), plan.CopyTimestamps);
SnapshotCopier.PruneTo(CharDir(LocalRoot, uid), 15);
_state.Set(uid, new LocalCharState(plan.NewGen, plan.NewHead));
_log.LogMessage((object)$"[CLOUDWARD] {uid}: pulled {plan.CopyTimestamps.Count} snapshot(s) <- share.");
return true;
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] " + uid + ": pull failed (" + ex.Message + ")."));
return false;
}
}
private bool ExecPush(string uid, ReconcilePlan plan, SyncLedger ledger)
{
//IL_004f: Unknown result type (might be due to invalid IL or missing references)
//IL_0059: Expected O, but got Unknown
//IL_006c: Unknown result type (might be due to invalid IL or missing references)
//IL_0076: Expected O, but got Unknown
try
{
SafeCopy(CharDir(LocalRoot, uid), CharDir(TargetPath, uid), plan.CopyTimestamps);
SnapshotCopier.PruneTo(CharDir(TargetPath, uid), 15);
ledger.Set(uid, new LedgerEntry(plan.NewGen, plan.NewHead, Plugin.DeviceName.Value));
_state.Set(uid, new LocalCharState(plan.NewGen, plan.NewHead));
_log.LogMessage((object)$"[CLOUDWARD] {uid}: pushed {plan.CopyTimestamps.Count} snapshot(s) -> share (gen {plan.NewGen}).");
return true;
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] " + uid + ": push failed (" + ex.Message + ")."));
return false;
}
}
private static void SafeCopy(string srcCharDir, string dstCharDir, IEnumerable<string> timestamps)
{
SnapshotCopier.CopySnapshots(srcCharDir, dstCharDir, timestamps);
}
private static IReadOnlyList<string> MissingList(CharTree from, CharTree to)
{
HashSet<string> have = new HashSet<string>(to.Snapshots, StringComparer.Ordinal);
return from.Snapshots.Where((string s) => !have.Contains(s)).ToList();
}
private static CharTree Tree(Dictionary<string, CharTree> map, string uid)
{
if (!map.TryGetValue(uid, out var value))
{
return null;
}
return value;
}
private LocalSyncState LoadState()
{
//IL_0046: Unknown result type (might be due to invalid IL or missing references)
//IL_004c: Expected O, but got Unknown
//IL_000c: Unknown result type (might be due to invalid IL or missing references)
try
{
return (LocalSyncState)(File.Exists(StatePath) ? ((object)LocalSyncState.Parse(File.ReadAllText(StatePath))) : ((object)new LocalSyncState()));
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] state read failed (" + ex.Message + ")."));
return new LocalSyncState();
}
}
private void SaveState()
{
try
{
File.WriteAllText(StatePath, _state.Serialize());
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] state write failed (" + ex.Message + ")."));
}
}
private SyncLedger LoadLedger()
{
//IL_0048: Unknown result type (might be due to invalid IL or missing references)
//IL_004e: Expected O, but got Unknown
//IL_000d: Unknown result type (might be due to invalid IL or missing references)
try
{
return (SyncLedger)(File.Exists(LedgerPath) ? ((object)SyncLedger.Parse(File.ReadAllText(LedgerPath))) : ((object)new SyncLedger()));
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] ledger read failed (" + ex.Message + ")."));
return new SyncLedger();
}
}
private void SaveLedger(SyncLedger ledger)
{
try
{
Directory.CreateDirectory(TargetPath);
string text = LedgerPath + ".tmp";
File.WriteAllText(text, ledger.Serialize());
if (File.Exists(LedgerPath))
{
File.Delete(LedgerPath);
}
File.Move(text, LedgerPath);
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] ledger write failed (" + ex.Message + ")."));
}
}
public bool EnsureMountLive(string mountRoot)
{
//IL_0064: Unknown result type (might be due to invalid IL or missing references)
//IL_0069: Unknown result type (might be due to invalid IL or missing references)
//IL_006b: Unknown result type (might be due to invalid IL or missing references)
//IL_006f: Unknown result type (might be due to invalid IL or missing references)
//IL_0072: Invalid comparison between Unknown and I4
if (string.IsNullOrEmpty(mountRoot))
{
_lastMountLive = false;
return false;
}
bool flag;
try
{
flag = File.Exists(Path.Combine(mountRoot, Plugin.MarkerFileName.Value));
}
catch
{
flag = false;
}
LocalSyncState state = _state;
bool flag2 = state != null && state.IsBootstrapped(mountRoot);
bool flag3 = ShareHasData(mountRoot);
bool flag4 = !Plugin.AutoCreateMarker.Value;
MountDecision val = MountGuard.Decide(flag, flag2, flag3, DriveReady(mountRoot), flag4);
if ((int)val != 0)
{
if ((int)val == 2)
{
bool flag5 = CreateMarker(mountRoot, flag3);
if (flag5 && _state != null && !_state.IsBootstrapped(mountRoot))
{
_state.MarkBootstrapped(mountRoot);
SaveState();
}
_lastMountLive = flag5;
return flag5;
}
_lastMountLive = false;
return false;
}
_lastMountLive = true;
return true;
}
private static bool ShareHasData(string mountRoot)
{
try
{
return Directory.Exists(Path.Combine(mountRoot, "SaveGames")) || File.Exists(Path.Combine(mountRoot, ".cloudward-ledger"));
}
catch
{
return false;
}
}
private bool CreateMarker(string mountRoot, bool recovery)
{
try
{
Directory.CreateDirectory(mountRoot);
string text = Path.Combine(mountRoot, Plugin.MarkerFileName.Value);
File.WriteAllText(text, "Cloudward mount marker — do not delete.\n");
_log.LogMessage((object)("[CLOUDWARD] auto-created mount marker at " + text + (recovery ? " (share has data — restored a missing marker)." : " (first run for this mount path).")));
return true;
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] could not auto-create marker in '" + mountRoot + "' (" + ex.Message + "); OFFLINE."));
return false;
}
}
public IReadOnlyList<string> PendingForks()
{
LocalSyncState state = _state;
return ((state != null) ? state.PendingForks.ToList() : null) ?? new List<string>();
}
public string CurrentHolder()
{
SessionLock sessionLock = _lock;
if (sessionLock == null)
{
return null;
}
LockStamp obj = sessionLock.Read();
if (obj == null)
{
return null;
}
return obj.Device;
}
private static bool DriveReady(string mountRoot)
{
try
{
if (mountRoot.Length >= 2 && mountRoot[1] == ':' && char.IsLetter(mountRoot[0]))
{
string text = mountRoot.Substring(0, 2);
char directorySeparatorChar = Path.DirectorySeparatorChar;
return new DriveInfo(text + directorySeparatorChar).IsReady;
}
}
catch
{
}
return true;
}
}
}
namespace Cloudward.Patches
{
[HarmonyPatch(typeof(NetworkLevelLoader), "OnApplicationQuit")]
internal static class AppQuit
{
private static void Postfix()
{
Plugin.Coordinator?.PushAndReleaseAtQuit();
}
}
[HarmonyPatch(typeof(SaveManager), "Init")]
internal static class SaveManagerInit
{
private static void Postfix()
{
Plugin.Coordinator?.TryReconcileAtBoot();
}
}
}
namespace Cloudward.Lock
{
internal sealed class SessionLock
{
public const string LockFileName = ".owner.lock";
private readonly string _lockPath;
private readonly string _device;
private readonly int _pid;
private readonly ManualLogSource _log;
public string Device => _device;
public SessionLock(string targetDir, string device, int pid, ManualLogSource log)
{
_lockPath = Path.Combine(targetDir, ".owner.lock");
_device = device;
_pid = pid;
_log = log;
}
public LockStamp Read()
{
try
{
if (!File.Exists(_lockPath))
{
return null;
}
string text = File.ReadAllText(_lockPath);
LockStamp val = default(LockStamp);
return LockStamp.TryParse(text, ref val) ? val : null;
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] could not read lock (" + ex.GetType().Name + ": " + ex.Message + "); treating as absent."));
return null;
}
}
public LeaseDecision Decide(DateTime nowUtc, int staleSeconds, LockHeldPolicy policy)
{
//IL_0014: Unknown result type (might be due to invalid IL or missing references)
//IL_0015: Unknown result type (might be due to invalid IL or missing references)
return LockLease.Decide(nowUtc, Read(), _device, _pid, staleSeconds, policy);
}
public bool Write(DateTime nowUtc)
{
//IL_003a: Unknown result type (might be due to invalid IL or missing references)
try
{
string directoryName = Path.GetDirectoryName(_lockPath);
if (!string.IsNullOrEmpty(directoryName))
{
Directory.CreateDirectory(directoryName);
}
string text = _lockPath + ".tmp";
File.WriteAllText(text, new LockStamp(_device, _pid, nowUtc).Serialize());
if (File.Exists(_lockPath))
{
File.Delete(_lockPath);
}
File.Move(text, _lockPath);
return true;
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] could not write lock (" + ex.GetType().Name + ": " + ex.Message + ")."));
return false;
}
}
public void ReleaseIfOwn()
{
try
{
LockStamp val = Read();
if (val != null && !string.Equals(val.Device, _device, StringComparison.OrdinalIgnoreCase))
{
_log.LogMessage((object)("[CLOUDWARD] lock now held by " + val.Device + "; leaving it."));
}
else if (File.Exists(_lockPath))
{
File.Delete(_lockPath);
}
}
catch (Exception ex)
{
_log.LogWarning((object)("[CLOUDWARD] could not release lock (" + ex.GetType().Name + ": " + ex.Message + "); it will self-heal after the stale window."));
}
}
}
}