diff --git a/.gitignore b/.gitignore
index 6c789ad..a42fb76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -404,4 +404,6 @@ build/
dist/
*.spec
-# End of https://www.toptal.com/developers/gitignore/api/csharp
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/csharp
+
+run.bat
\ No newline at end of file
diff --git a/PolyMod.csproj b/PolyMod.csproj
index 22b404b..4f6484f 100644
--- a/PolyMod.csproj
+++ b/PolyMod.csproj
@@ -11,7 +11,7 @@
IL2CPP
PolyMod
- 1.2.11
+ 1.3.0-pre
2.16.4.15698
PolyModdingTeam
The Battle of Polytopia's mod loader.
diff --git a/src/Multiplayer/Client.cs b/src/Multiplayer/Client.cs
new file mode 100644
index 0000000..4ff1a93
--- /dev/null
+++ b/src/Multiplayer/Client.cs
@@ -0,0 +1,379 @@
+using HarmonyLib;
+using Il2CppMicrosoft.AspNetCore.SignalR.Client;
+using PolyMod.Multiplayer.ViewModels;
+using Polytopia.Data;
+using PolytopiaBackendBase;
+using PolytopiaBackendBase.Common;
+using PolytopiaBackendBase.Game;
+using PolytopiaBackendBase.Game.BindingModels;
+using UnityEngine;
+using Newtonsoft.Json;
+
+namespace PolyMod.Multiplayer;
+
+public static class Client
+{
+ internal const string DEFAULT_SERVER_URL = "https://dev.polydystopia.xyz";
+ internal const string LOCAL_SERVER_URL = "http://localhost:5051/";
+ private const string GldMarker = "##GLD:";
+ internal static bool allowGldMods = false;
+
+ // Cache parsed GLD by game Seed to handle rewinds/reloads
+ private static readonly Dictionary _gldCache = new();
+ private static readonly Dictionary _versionCache = new(); // Seed -> modGldVersion
+
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Client));
+ BuildConfig buildConfig = BuildConfigHelper.GetSelectedBuildConfig();
+ buildConfig.buildServerURL = BuildServerURL.Custom;
+ buildConfig.customServerURL = Plugin.config.backendUrl;
+
+ Plugin.logger.LogInfo($"Multiplayer> Server URL set to: {Plugin.config.backendUrl}");
+ Plugin.logger.LogInfo("Multiplayer> GLD patches applied");
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(MultiplayerScreen), nameof(MultiplayerScreen.Show))]
+ public static void MultiplayerScreen_Show(MultiplayerScreen __instance)
+ {
+ __instance.multiplayerSelectionScreen.TournamentsButton.gameObject.SetActive(false);
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen), nameof(StartScreen.Start))]
+ private static void StartScreen_Start(StartScreen __instance)
+ {
+ __instance.highscoreButton.gameObject.SetActive(false);
+ __instance.weeklyChallengesButton.gameObject.SetActive(false);
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(SystemInfo), nameof(SystemInfo.deviceUniqueIdentifier), MethodType.Getter)]
+ public static void SteamClient_get_SteamId(ref string __result)
+ {
+ if (Plugin.config.overrideDeviceId != string.Empty)
+ {
+ __result = Plugin.config.overrideDeviceId;
+ }
+ }
+
+
+ ///
+ /// After GameState deserialization, check for trailing GLD version ID and set mockedGameLogicData.
+ /// The server appends "##GLD:" + modGldVersion (int) after the normal serialized data.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(GameState), nameof(GameState.Deserialize))]
+ private static void Deserialize_Postfix(GameState __instance, BinaryReader __0)
+ {
+ if(!allowGldMods) return;
+
+ Plugin.logger?.LogDebug("Deserialize_Postfix: Entered");
+
+ try
+ {
+ var reader = __0;
+ if (reader == null)
+ {
+ Plugin.logger?.LogWarning("Deserialize_Postfix: reader is null");
+ return;
+ }
+
+ var position = reader.BaseStream.Position;
+ var length = reader.BaseStream.Length;
+ var remaining = length - position;
+
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Stream position={position}, length={length}, remaining={remaining}");
+
+ // Check if there's more data after normal deserialization
+ if (position >= length)
+ {
+ Plugin.logger?.LogDebug("Deserialize_Postfix: No trailing data (position >= length)");
+
+ var sd = __instance.Seed;
+ if (_gldCache.TryGetValue(sd, out var cachedGld))
+ {
+ __instance.mockedGameLogicData = cachedGld;
+ var cachedVersion = _versionCache.GetValueOrDefault(sd, -1);
+ Plugin.logger?.LogInfo($"Deserialize_Postfix: Applied cached GLD for Seed={sd}, ModGldVersion={cachedVersion}");
+ }
+ return;
+ }
+
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Found {remaining} bytes of trailing data, attempting to read marker");
+
+ var marker = reader.ReadString();
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Read marker string: '{marker}'");
+
+ if (marker != GldMarker)
+ {
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Marker mismatch - expected '{GldMarker}', got '{marker}'");
+ return;
+ }
+
+ Plugin.logger?.LogInfo($"Deserialize_Postfix: Found GLD marker '{GldMarker}'");
+
+ var modGldVersion = reader.ReadInt32();
+ Plugin.logger?.LogInfo($"Deserialize_Postfix: Found embedded ModGldVersion: {modGldVersion}");
+
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Fetching GLD from server for version {modGldVersion}");
+ var gldJson = FetchGldById(modGldVersion);
+ if (string.IsNullOrEmpty(gldJson))
+ {
+ Plugin.logger?.LogError($"Deserialize_Postfix: Failed to fetch GLD for ModGldVersion: {modGldVersion}");
+ return;
+ }
+
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Parsing GLD JSON ({gldJson.Length} chars)");
+
+ var customGld = new GameLogicData();
+ customGld.Parse(gldJson);
+ __instance.mockedGameLogicData = customGld;
+
+ // Cache for subsequent deserializations (rewinds, reloads)
+ var seed = __instance.Seed;
+ _gldCache[seed] = customGld;
+ _versionCache[seed] = modGldVersion;
+
+ Plugin.logger?.LogInfo($"Deserialize_Postfix: Successfully set mockedGameLogicData from ModGldVersion: {modGldVersion}, cached for Seed={seed}");
+ }
+ catch (EndOfStreamException)
+ {
+ Plugin.logger?.LogDebug("Deserialize_Postfix: EndOfStreamException - no trailing data");
+ }
+ catch (Exception ex)
+ {
+ Plugin.logger?.LogError($"Deserialize_Postfix: Exception: {ex.GetType().Name}: {ex.Message}");
+ Plugin.logger?.LogDebug($"Deserialize_Postfix: Stack trace: {ex.StackTrace}");
+ }
+ }
+
+ ///
+ /// Fetch GLD from server using ModGldVersion ID
+ ///
+ private static string? FetchGldById(int modGldVersion)
+ {
+ if(!allowGldMods) return null;
+ try
+ {
+ using var client = new HttpClient();
+ var url = $"{Plugin.config.backendUrl.TrimEnd('/')}/api/mods/gld/{modGldVersion}";
+ Plugin.logger?.LogDebug($"FetchGldById: Requesting URL: {url}");
+
+ var response = client.GetAsync(url).Result;
+ Plugin.logger?.LogDebug($"FetchGldById: Response status: {response.StatusCode}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var gld = response.Content.ReadAsStringAsync().Result;
+ Plugin.logger?.LogInfo($"FetchGldById: Successfully fetched mod GLD ({gld.Length} chars)");
+ return gld;
+ }
+
+ var errorContent = response.Content.ReadAsStringAsync().Result;
+ Plugin.logger?.LogError($"FetchGldById: Failed with status {response.StatusCode}: {errorContent}");
+ }
+ catch (Exception ex)
+ {
+ Plugin.logger?.LogError($"FetchGldById: Exception: {ex.GetType().Name}: {ex.Message}");
+ if (ex.InnerException != null)
+ {
+ Plugin.logger?.LogError($"FetchGldById: Inner exception: {ex.InnerException.Message}");
+ }
+ }
+ return null;
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.StartLobbyGame))]
+ private static bool BackendAdapter_StartLobbyGame_Modded(
+ ref Il2CppSystem.Threading.Tasks.Task> __result,
+ BackendAdapter __instance,
+ StartLobbyBindingModel model)
+ {
+ Plugin.logger.LogInfo("Multiplayer> BackendAdapter_StartLobbyGame_Modded");
+ var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>();
+
+ _ = HandleStartLobbyGameModded(taskCompletionSource, __instance, model);
+
+ __result = taskCompletionSource.Task;
+
+ return false;
+ }
+
+ private static async System.Threading.Tasks.Task HandleStartLobbyGameModded(
+ Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs,
+ BackendAdapter instance,
+ StartLobbyBindingModel model)
+ {
+ try
+ {
+ var lobbyResponse = await PolytopiaBackendAdapter.Instance.GetLobby(new GetLobbyBindingModel
+ {
+ LobbyId = model.LobbyId
+ });
+
+ Plugin.logger.LogInfo($"Multiplayer> Lobby processed {lobbyResponse.Success}");
+ LobbyGameViewModel lobbyGameViewModel = lobbyResponse.Data;
+ Plugin.logger.LogInfo("Multiplayer> Lobby received");
+
+ (byte[] serializedGameState, string gameSettingsJson) = CreateMultiplayerGame(
+ lobbyGameViewModel,
+ VersionManager.GameVersion,
+ VersionManager.GameLogicDataVersion
+ );
+
+ Plugin.logger.LogInfo("Multiplayer> GameState and Settiings created");
+
+ var setupGameDataViewModel = new SetupGameDataViewModel
+ {
+ lobbyId = lobbyGameViewModel.Id.ToString(),
+ serializedGameState = serializedGameState,
+ gameSettingsJson = gameSettingsJson
+ };
+
+ var setupData = System.Text.Json.JsonSerializer.Serialize(setupGameDataViewModel);
+
+ var serverResponse = await instance.HubConnection.InvokeAsync>(
+ "StartLobbyGameModded",
+ setupData,
+ Il2CppSystem.Threading.CancellationToken.None
+ );
+ Plugin.logger.LogInfo("Multiplayer> Invoked StartLobbyGameModded");
+ tcs.SetResult(serverResponse);
+ }
+ catch (Exception ex)
+ {
+ Plugin.logger.LogError("Multiplayer> Error during HandleStartLobbyGameModded: " + ex.Message);
+ tcs.SetException(new Il2CppSystem.Exception(ex.Message));
+ }
+ }
+
+ public static (byte[] serializedGameState, string gameSettingsJson) CreateMultiplayerGame(LobbyGameViewModel lobby,
+ int gameVersion, int gameLogicVersion)
+ {
+ var lobbyMapSize = lobby.MapSize;
+ var settings = new GameSettings();
+ settings.ApplyLobbySettings(lobby);
+ if (settings.LiveGamePreset)
+ {
+ settings.SetLiveModePreset();
+ }
+ foreach (var participatorViewModel in lobby.Participators)
+ {
+ var humanPlayer = new PlayerData
+ {
+ type = PlayerDataType.LocalUser,
+ state = PlayerDataFriendshipState.Accepted,
+ knownTribe = true,
+ tribe = (TribeType)participatorViewModel.SelectedTribe,
+ tribeMix = (TribeType)participatorViewModel.SelectedTribe,
+ skinType = (SkinType)participatorViewModel.SelectedTribeSkin,
+ defaultName = participatorViewModel.GetNameInternal()
+ };
+ humanPlayer.profile.id = participatorViewModel.UserId;
+ humanPlayer.profile.SetName(participatorViewModel.GetNameInternal());
+ SerializationHelpers.FromByteArray(participatorViewModel.AvatarStateData, out var avatarState);
+ humanPlayer.profile.avatarState = avatarState;
+
+ settings.AddPlayer(humanPlayer);
+ }
+
+ foreach (var botDifficulty in lobby.Bots)
+ {
+ var botGuid = Il2CppSystem.Guid.NewGuid();
+
+ var botPlayer = new PlayerData
+ {
+ type = PlayerDataType.Bot,
+ state = PlayerDataFriendshipState.Accepted,
+ knownTribe = true,
+ tribe = Enum.GetValues().Where(t => t != TribeType.None)
+ .OrderBy(x => Il2CppSystem.Guid.NewGuid()).First()
+ };
+ ;
+ botPlayer.botDifficulty = (BotDifficulty)botDifficulty;
+ botPlayer.skinType = SkinType.Default;
+ botPlayer.defaultName = "Bot" + botGuid;
+ botPlayer.profile.id = botGuid;
+
+ settings.AddPlayer(botPlayer);
+ }
+
+ GameState gameState = new GameState()
+ {
+ Version = gameVersion,
+ Settings = settings,
+ PlayerStates = new Il2CppSystem.Collections.Generic.List()
+ };
+
+ for (int index = 0; index < settings.GetPlayerCount(); ++index)
+ {
+ PlayerData player = settings.GetPlayer(index);
+ if (player.type != PlayerDataType.Bot)
+ {
+ var nullableGuid = new Il2CppSystem.Nullable(player.profile.id);
+ if (!nullableGuid.HasValue)
+ {
+ throw new Exception("GUID was not set properly!");
+ }
+ PlayerState playerState = new PlayerState()
+ {
+ Id = (byte)(index + 1),
+ AccountId = nullableGuid,
+ AutoPlay = player.type == PlayerDataType.Bot,
+ UserName = player.GetNameInternal(),
+ tribe = player.tribe,
+ tribeMix = player.tribeMix,
+ hasChosenTribe = true,
+ skinType = player.skinType
+ };
+ gameState.PlayerStates.Add(playerState);
+ Plugin.logger.LogInfo($"Multiplayer> Created player: {playerState}");
+ }
+ else
+ {
+ GameStateUtils.AddAIOpponent(gameState, GameStateUtils.GetRandomPickableTribe(gameState),
+ GameSettings.HandicapFromDifficulty(player.botDifficulty), player.skinType);
+ }
+ }
+
+ GameStateUtils.SetPlayerColors(gameState);
+ GameStateUtils.AddNaturePlayer(gameState);
+
+ Plugin.logger.LogInfo("Multiplayer> Creating world...");
+
+ ushort num = (ushort)Math.Max(lobbyMapSize,
+ (int)MapDataExtensions.GetMinimumMapSize(gameState.PlayerCount));
+ gameState.Map = new MapData(num, num);
+ MapGeneratorSettings generatorSettings = settings.GetMapGeneratorSettings();
+ new MapGenerator().Generate(gameState, generatorSettings);
+
+ Plugin.logger.LogInfo($"Multiplayer> Creating initial state for {gameState.PlayerCount} players...");
+
+ foreach (PlayerState player in gameState.PlayerStates)
+ {
+ foreach (PlayerState otherPlayer in gameState.PlayerStates)
+ player.aggressions[otherPlayer.Id] = 0;
+
+ if (player.Id != byte.MaxValue && gameState.GameLogicData.TryGetData(player.tribe, out TribeData tribeData))
+ {
+ player.Currency = tribeData.startingStars;
+ TileData tile = gameState.Map.GetTile(player.startTile);
+ UnitState unitState = ActionUtils.TrainUnitScored(gameState, player, tile, tribeData.startingUnit);
+ unitState.attacked = false;
+ unitState.moved = false;
+ }
+ }
+
+ Plugin.logger.LogInfo("Multiplayer> Session created successfully");
+
+ gameState.CommandStack.Add((CommandBase)new StartMatchCommand((byte)1));
+
+ var serializedGameState = SerializationHelpers.ToByteArray(gameState, gameState.Version);
+
+ return (serializedGameState,
+ JsonConvert.SerializeObject(gameState.Settings));
+ }
+}
diff --git a/src/Multiplayer/ViewModels/IMonoServerResponseData.cs b/src/Multiplayer/ViewModels/IMonoServerResponseData.cs
new file mode 100644
index 0000000..3b0a835
--- /dev/null
+++ b/src/Multiplayer/ViewModels/IMonoServerResponseData.cs
@@ -0,0 +1,5 @@
+namespace PolyMod.Multiplayer.ViewModels;
+
+public interface IMonoServerResponseData
+{
+}
\ No newline at end of file
diff --git a/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs b/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs
new file mode 100644
index 0000000..68f43f1
--- /dev/null
+++ b/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs
@@ -0,0 +1,10 @@
+
+namespace PolyMod.Multiplayer.ViewModels;
+public class SetupGameDataViewModel : IMonoServerResponseData
+{
+ public string lobbyId { get; set; }
+
+ public byte[] serializedGameState { get; set; }
+
+ public string gameSettingsJson { get; set; }
+}
\ No newline at end of file
diff --git a/src/Plugin.cs b/src/Plugin.cs
index 96ae942..924944c 100644
--- a/src/Plugin.cs
+++ b/src/Plugin.cs
@@ -24,7 +24,9 @@ internal record PolyConfig(
bool debug = false,
bool autoUpdate = true,
bool updatePrerelease = false,
- bool allowUnsafeIndexes = false
+ bool allowUnsafeIndexes = false,
+ string backendUrl = Multiplayer.Client.DEFAULT_SERVER_URL,
+ string overrideDeviceId = ""
);
///
@@ -132,6 +134,7 @@ public override void Load()
Hub.Init();
Main.Init();
+ Multiplayer.Client.Init();
}
///