diff --git a/.gitignore b/.gitignore
index a42fb76..3a4ab03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -406,4 +406,6 @@ dist/
# End of https://www.toptal.com/developers/gitignore/api/csharp
-run.bat
\ No newline at end of file
+run.bat
+
+.idea/
diff --git a/PolyMod.csproj b/PolyMod.csproj
index a6734ef..783658f 100644
--- a/PolyMod.csproj
+++ b/PolyMod.csproj
@@ -11,7 +11,7 @@
IL2CPP
PolyMod
- 1.2.17
+ 1.3.0-pre-android-1
2.17.2.16299
PolyModdingTeam
The Battle of Polytopia's mod loader.
diff --git a/resources/dystopia_icon.png b/resources/dystopia_icon.png
new file mode 100644
index 0000000..512162a
Binary files /dev/null and b/resources/dystopia_icon.png differ
diff --git a/src/Android/AndroidHandler.cs b/src/Android/AndroidHandler.cs
new file mode 100644
index 0000000..18a8d9f
--- /dev/null
+++ b/src/Android/AndroidHandler.cs
@@ -0,0 +1,89 @@
+using HarmonyLib;
+using PolytopiaBackendBase;
+using PolytopiaBackendBase.Auth;
+using UnityEngine;
+
+namespace PolyMod.Android;
+
+public static class AndroidHandler
+{
+ internal static void Init()
+ {
+ if (Application.platform != RuntimePlatform.Android) return;
+
+ Harmony.CreateAndPatchAll(typeof(AndroidHandler));
+ }
+
+ ///
+ /// On Android, bypass multiplayer requirements that depend on
+ /// Google Play login, push notifications, and purchases — none of which work
+ /// when running as a wrapper app with a different package identity.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(GameManager), nameof(GameManager.IsMultiplayerEnabled), MethodType.Getter)]
+ public static bool GameManager_IsMultiplayerEnabled(ref bool __result)
+ {
+ __result = true;
+ return false;
+ }
+
+ ///
+ /// Replace the Android login flow to skip Google Play Games SDK entirely.
+ /// Uses deviceUniqueIdentifier as the auth code for the Polydystopia backend.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(PolytopiaBackendAdapter), "LoginPlatformAndroid")]
+ public static bool LoginPlatformAndroid_Prefix(
+ ref Il2CppSystem.Threading.Tasks.Task> __result,
+ PolytopiaBackendAdapter __instance)
+ {
+ // Mark social login as cached so the post-login flow doesn't bail out
+ __instance.HasSocialLoginCached = true;
+
+ var model = new LoginGooglePlayBindingModel();
+ model.AuthCode = SystemInfo.deviceUniqueIdentifier;
+ model.DeviceId = SystemInfo.deviceUniqueIdentifier;
+
+ Plugin.logger.LogInfo($"Multiplayer> Android login with DeviceId: {model.DeviceId}");
+ __result = __instance.LoginGooglePlay(model);
+ return false;
+ }
+
+ ///
+ /// On android Firebase cannot initialize inside the launcher process (its config lives in the game APK's resources, and the native lib may be unreachable there).
+ /// We try to skip Firebase completely.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(AnalyticsManager), nameof(AnalyticsManager.IsAnalyticsEnabled))]
+ private static bool AnalyticsManager_IsAnalyticsEnabled(ref bool __result)
+ {
+ __result = false;
+ return false;
+ }
+
+ ///
+ /// On android Firebase cannot initialize inside the launcher process (its config lives in the game APK's resources, and the native lib may be unreachable there).
+ /// We try to skip Firebase completely. isFirebaseInitialized deliberately stays false:
+ /// pretending Firebase is up could wake isFirebaseInitialized-guarded code paths
+ /// (e.g. HandleOpenedThroughNotification on every app resume).
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(FirebaseMessagingManager), nameof(FirebaseMessagingManager.Init))]
+ private static bool FirebaseMessagingManager_Init()
+ {
+ return false;
+ }
+
+ ///
+ /// RequestPushNotificationPermissions (the push-notification row in LoginDetails) calls
+ /// InitAsync directly, bypassing Init — with isFirebaseInitialized kept false that would
+ /// still reach Firebase, so hand back a completed task instead.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(FirebaseMessagingManager), nameof(FirebaseMessagingManager.InitAsync))]
+ private static bool FirebaseMessagingManager_InitAsync(ref Il2CppSystem.Threading.Tasks.Task __result)
+ {
+ __result = Il2CppSystem.Threading.Tasks.Task.CompletedTask;
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Managers/Compatibility.cs b/src/Managers/Compatibility.cs
index 1d55bb7..9359df3 100644
--- a/src/Managers/Compatibility.cs
+++ b/src/Managers/Compatibility.cs
@@ -21,6 +21,15 @@ internal static class Compatibility
internal static bool shouldResetSettings = false;
private static bool sawSignatureWarning;
+ ///
+ /// Whether all loaded mods are client only. If at least one non client only mod exists this returns false.
+ ///
+ ///
+ public static bool IsClientOnly()
+ {
+ return Registry.mods.Select(modPair => modPair.Value).All(mod => mod.client);
+ }
+
///
/// Hashes the signatures of all loaded mods to create a checksum.
///
diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs
index bccc5fc..d786dec 100644
--- a/src/Managers/Visual.cs
+++ b/src/Managers/Visual.cs
@@ -55,6 +55,10 @@ public record SkinInfo(int idx, string id, SkinData? skinData);
/// A dictionary of custom widths for basic popups.
public static Dictionary basicPopupWidths = new();
+ /// Original font sizes of popup buttons, so relayouts rescale from the prefab size.
+ private static readonly Dictionary popupButtonFontSizes = new();
+ /// Original scroll viewport bottom offsets of popups, so relayouts don't compound.
+ private static readonly Dictionary popupScrollBottoms = new();
/// Represents information about a unit prefab.
public struct UnitPrefabInfo
{
@@ -778,7 +782,86 @@ private static void UpdateWidth(PopupBase __instance)
{
int id = __instance.GetInstanceID();
if (basicPopupWidths.ContainsKey(id))
- __instance.rectTransform.SetWidth(basicPopupWidths[id]);
+ {
+ float maxWidth = UIManager.GetUIWidth() - 40f;
+ float width = Mathf.Min(basicPopupWidths[id], maxWidth);
+ __instance.rectTransform.SetWidth(width);
+ LayoutPopupButtons(__instance, width);
+ }
+ }
+
+ private const float BUTTON_GAP = 10f;
+ private const float BUTTON_ROW_PADDING = 40f;
+
+ private static void LayoutPopupButtons(PopupBase popup, float popupWidth)
+ {
+ var legacy = popup.TryCast();
+ if (legacy == null || legacy.buttonContainer == null) return;
+ var buttons = legacy.buttonContainer.Buttons;
+ if (buttons == null || buttons.Length < 2) return;
+
+ float rowWidth = popupWidth - BUTTON_ROW_PADDING;
+
+ foreach (UITextButton button in buttons)
+ {
+ int id = button.GetInstanceID();
+ if (!popupButtonFontSizes.ContainsKey(id))
+ popupButtonFontSizes[id] = button.FontSize;
+ else
+ button.FontSize = popupButtonFontSizes[id];
+ button.UpdateSize();
+ float width = button.rectTransform.GetWidth();
+ if (width > rowWidth)
+ {
+ button.FontSize *= rowWidth / width;
+ button.UpdateSize();
+ }
+ }
+
+ List> rows = new();
+ float cursor = 0f;
+ foreach (UITextButton button in buttons)
+ {
+ float width = button.rectTransform.GetWidth();
+ if (rows.Count == 0 || cursor + width > rowWidth)
+ {
+ rows.Add(new());
+ cursor = 0f;
+ }
+ rows[^1].Add(button);
+ cursor += width + BUTTON_GAP;
+ }
+
+ float rowHeight = buttons[0].rectTransform.GetHeight() + BUTTON_GAP;
+ for (int r = 0; r < rows.Count; r++)
+ {
+ float total = -BUTTON_GAP;
+ foreach (UITextButton button in rows[r])
+ total += button.rectTransform.GetWidth() + BUTTON_GAP;
+ float x = -total / 2f;
+ float y = (rows.Count - 1 - r) * rowHeight;
+ foreach (UITextButton button in rows[r])
+ {
+ button.rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
+ button.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
+ button.rectTransform.pivot = new Vector2(0f, 0.5f);
+ button.rectTransform.anchoredPosition = new Vector2(x, y);
+ x += button.rectTransform.GetWidth() + BUTTON_GAP;
+ }
+ }
+
+ float extra = (rows.Count - 1) * rowHeight;
+ float maxHeight = (ScreenManager.SafeHeight - 20f) * UICanvasScalerHelper.GetInvertedUIScale();
+ popup.rectTransform.SetHeight(Mathf.Min(popup.rectTransform.GetHeight() + extra, maxHeight));
+
+ if (popup.scrollRect != null)
+ {
+ var viewport = popup.scrollRect.GetComponent();
+ int popupId = popup.GetInstanceID();
+ if (!popupScrollBottoms.ContainsKey(popupId))
+ popupScrollBottoms[popupId] = viewport.offsetMin.y;
+ viewport.offsetMin = new Vector2(viewport.offsetMin.x, popupScrollBottoms[popupId] + extra);
+ }
}
/// Sets the attacker's tribe before a unit attacks.
@@ -809,12 +892,19 @@ private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType)
}
}
- /// Removes a popup's custom width when it is hidden.
+ /// Removes a popup's custom width and cached button font sizes when it is hidden.
[HarmonyPostfix]
[HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))]
private static void PopupBase_Hide(PopupBase __instance)
{
basicPopupWidths.Remove(__instance.GetInstanceID());
+ popupScrollBottoms.Remove(__instance.GetInstanceID());
+ var legacy = __instance.TryCast();
+ if (legacy != null && legacy.buttonContainer != null && legacy.buttonContainer.Buttons != null)
+ {
+ foreach (UITextButton button in legacy.buttonContainer.Buttons)
+ popupButtonFontSizes.Remove(button.GetInstanceID());
+ }
}
[HarmonyPrefix]
diff --git a/src/Multiplayer/Dystopia.cs b/src/Multiplayer/Dystopia.cs
new file mode 100644
index 0000000..9848f3d
--- /dev/null
+++ b/src/Multiplayer/Dystopia.cs
@@ -0,0 +1,234 @@
+using Cpp2IL.Core.Extensions;
+using HarmonyLib;
+using Il2CppInterop.Runtime;
+using PolyMod.Managers;
+using PolytopiaBackendBase;
+using TMPro;
+using UnityEngine;
+
+namespace PolyMod.Multiplayer;
+
+///
+/// Adds a "Dystopia" start-screen button with a popup to switch the backend server at runtime.
+///
+public static class Dystopia
+{
+ private const int POPUP_WIDTH = 1400;
+
+ internal const string OFFICIAL_SERVER_URL = "https://polytopia-prod.net/";
+
+ private record ServerEntry(string name, string url, bool official = false);
+
+ private static readonly ServerEntry[] SERVERS =
+ {
+ new("Official", OFFICIAL_SERVER_URL, official: true),
+ new("Dystopia", "https://polydystopia.xyz"),
+ new("Dystopia Dev", "https://dev.polydystopia.xyz"),
+ };
+
+ ///
+ /// Default backend: the official server, except on Android
+ ///
+ internal static string DefaultServerUrl()
+ {
+ return Application.platform == RuntimePlatform.Android
+ ? Client.DEFAULT_SERVER_URL
+ : OFFICIAL_SERVER_URL;
+ }
+
+ private static UIRoundButton_UI2? dystopiaButton = null;
+
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Dystopia));
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))]
+ private static void StartScreen_UI2_Init(StartScreen_UI2 __instance, RectTransform transform)
+ {
+ if (dystopiaButton != null)
+ {
+ UnityEngine.Object.Destroy(dystopiaButton.gameObject);
+ }
+ dystopiaButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
+ dystopiaButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("dystopia_icon.png").ReadBytes());
+ dystopiaButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(ShowDystopiaPopup));
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))]
+ private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize)
+ {
+ if (dystopiaButton == null)
+ {
+ Plugin.logger.LogWarning("Dystopia button is null when running layout!");
+ return;
+ }
+ dystopiaButton.iconContainer.gameObject.SetActive(false);
+ dystopiaButton.outline.gameObject.SetActive(false);
+ dystopiaButton.bg.color = Color.white;
+ dystopiaButton.Text = "Dystopia";
+ float num = 50f;
+
+ dystopiaButton.SetPosition(screenSize.safeRect.Right - (num * 4.0f), screenSize.safeRect.Top - num);
+ }
+
+ internal static void ShowDystopiaPopup()
+ {
+ string current = Normalize(Plugin.config.backendUrl);
+
+ BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
+ popup.Header = "Dystopia";
+ popup.Description = $"Current server:\n{Plugin.config.backendUrl}";
+
+ List buttons = new()
+ {
+ new("buttons.back"),
+ };
+ foreach (ServerEntry server in SERVERS)
+ {
+ bool isActive = Normalize(server.url) == current;
+ // The official server rejects our DeviceId login, so it stays greyed out on Android.
+ bool isDisabled = isActive || (server.official && Application.platform == RuntimePlatform.Android);
+ string url = server.url;
+ buttons.Add(new(
+ isActive ? server.name + " (active)" : server.name,
+ isDisabled ? PopupBase.PopupButtonData.States.Disabled : PopupBase.PopupButtonData.States.None,
+ DelegateSupport.ConvertDelegate((System.Action)(() => SwitchServer(url)))
+ ));
+ }
+ buttons.Add(new(
+ "Custom...",
+ callback: DelegateSupport.ConvertDelegate(ShowCustomServerPopup)
+ ));
+ popup.buttonData = buttons.ToArray();
+ popup.ShowSetWidth(POPUP_WIDTH);
+ }
+
+ private static void ShowCustomServerPopup()
+ {
+ SearchFriendCodePopup popup = PopupManager.GetPopup("dystopiaCustomServerPopup");
+ TMP_InputField input = popup.inputfield;
+
+ input.onSubmit = new TMP_InputField.SubmitEvent();
+ input.onEndEdit = new TMP_InputField.SubmitEvent();
+ input.onValueChanged = new TMP_InputField.OnChangeEvent();
+ input.onSelect = new TMP_InputField.SelectionEvent();
+ input.onDeselect = new TMP_InputField.SelectionEvent();
+ input.contentType = TMP_InputField.ContentType.Standard;
+ input.characterLimit = 200;
+
+ var placeholder = input.placeholder != null ? input.placeholder.TryCast() : null;
+ if (placeholder != null)
+ {
+ placeholder.text = "Server URL or IP";
+ }
+
+ void OnConnect()
+ {
+ string url = input.text?.Trim() ?? "";
+ if (url.Length == 0)
+ {
+ return;
+ }
+ if (!url.StartsWith("http://") && !url.StartsWith("https://"))
+ {
+ url = "https://" + url;
+ }
+ if (!System.Uri.TryCreate(url, System.UriKind.Absolute, out _))
+ {
+ NotificationManager.Notify("Invalid server URL");
+ return;
+ }
+ popup.Hide();
+ SwitchServer(url);
+ }
+
+ popup.Show();
+ popup.Header = "Custom server";
+ popup.Description = "Enter the server URL or IP:";
+ popup.buttonContainer.ResetContainer();
+ popup.buttonData = new PopupBase.PopupButtonData[]
+ {
+ new("buttons.back"),
+ new(
+ "Connect",
+ callback: DelegateSupport.ConvertDelegate(OnConnect),
+ closesPopup: false
+ ),
+ };
+ input.SetTextWithoutNotify(Plugin.config.backendUrl);
+ }
+
+ private static async void SwitchServer(string url)
+ {
+ Plugin.logger.LogInfo($"Dystopia> Switching server to {url}");
+ Plugin.config = Plugin.config with { backendUrl = url };
+ Plugin.WriteConfig();
+
+ PolytopiaBackendAdapter adapter = PolytopiaBackendAdapter.Instance;
+
+ await adapter.CloseConnection();
+
+ BuildConfig buildConfig = BuildConfigHelper.GetSelectedBuildConfig();
+ buildConfig.buildServerURL = BuildServerURL.Custom;
+ buildConfig.customServerURL = url;
+
+ adapter.UseBackendUri(new Il2CppSystem.Uri(url));
+ adapter.UseHttpClient();
+
+ PurgeServerCaches();
+
+ adapter.ConnectionStatus = ConnectionStatus.None;
+ BackendEvents.BackendConnectionChanged(ConnectionStatus.Disconnected, false);
+
+ GameManager.GetLoginManager().Login(false, true);
+ Plugin.logger.LogInfo($"Dystopia> Reconnect to {url} initiated");
+ }
+
+ private static void PurgeServerCaches()
+ {
+ try
+ {
+ var remote = GameManager.GetRemoteGameDataManager();
+ remote.gameDataCache.Clear();
+ remote.matchmakingGameDataCache?.Clear();
+ remote.gameIdCache.Clear();
+ remote.hasLoadedGameDataCache = false;
+
+ var lobbies = GameManager.GetLobbyManager();
+ lobbies.cachedLobbies.Clear();
+ lobbies.hasCachedLobbies = false;
+
+ AccountManager.currentPlayerData = null;
+ AccountManager.friends = null;
+ AccountManager.friendViewModels = null;
+ AccountManager.playersStatuses?.Clear();
+ AccountManager.ClearCachedPlayerId();
+
+ DeleteIfExists(Paths.GetUserProfileCachePath());
+ DeleteIfExists(Paths.GetStartupDataPath());
+
+ GameManager.GetWeeklyChallengeModel().ClearData();
+ GameManager.ActionableGamesCount = 0;
+ }
+ catch (System.Exception e)
+ {
+ Plugin.logger.LogWarning($"Dystopia> Failed to purge some server caches: {e}");
+ }
+ }
+
+ private static void DeleteIfExists(string path)
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string Normalize(string url)
+ {
+ return url.TrimEnd('/').ToLowerInvariant();
+ }
+}
diff --git a/src/Multiplayer/ModMultiplayer.cs b/src/Multiplayer/ModMultiplayer.cs
new file mode 100644
index 0000000..115c09f
--- /dev/null
+++ b/src/Multiplayer/ModMultiplayer.cs
@@ -0,0 +1,272 @@
+using HarmonyLib;
+using Il2CppMicrosoft.AspNetCore.SignalR.Client;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using PolyMod.Managers;
+using PolyMod.Multiplayer.ViewModels;
+using Polytopia.Data;
+using PolytopiaBackendBase;
+using PolytopiaBackendBase.Common;
+using PolytopiaBackendBase.Game;
+using PolytopiaBackendBase.Game.BindingModels;
+using UnityEngine;
+
+namespace PolyMod.Multiplayer;
+
+public class ModMultiplayer
+{
+ internal static void Init()
+ {
+ if (Compatibility.IsClientOnly())
+ {
+ Plugin.logger?.LogInfo($"All loaded mods are client only. Skipping modded multiplayer initialization.");
+
+ return;
+ }
+
+ Plugin.logger?.LogInfo($"Starting modded multiplayer initialization.");
+
+ Harmony.CreateAndPatchAll(typeof(ModMultiplayer));
+
+
+ Plugin.logger?.LogInfo($"Finished modded multiplayer initialization.");
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.CreateLobby))]
+ private static bool BackendAdapter_CreateLobby(
+ ref Il2CppSystem.Threading.Tasks.Task> __result,
+ BackendAdapter __instance,
+ CreateLobbyBindingModel model)
+ {
+ Plugin.logger.LogInfo("Multiplayer> BackendAdapter_CreateLobby");
+ var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>();
+
+ _ = HandleCreateLobbyModded(taskCompletionSource, __instance, model);
+
+ __result = taskCompletionSource.Task;
+
+ return false;
+ }
+
+ private static async System.Threading.Tasks.Task HandleCreateLobbyModded(
+ Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs,
+ BackendAdapter instance,
+ CreateLobbyBindingModel model)
+ {
+ try
+ {
+ var payload = JObject.FromObject(model);
+ payload["IsModded"] = new JValue(true);
+ payload["Checksum"] = new JValue(Compatibility.checksum);
+
+ var serverResponse = await instance.HubConnection.InvokeAsync>(
+ "CreateLobby",
+ payload,
+ Il2CppSystem.Threading.CancellationToken.None
+ );
+ Plugin.logger.LogInfo("Multiplayer> Invoked CreateLobby with mod info");
+ tcs.SetResult(serverResponse);
+ }
+ catch (Exception ex)
+ {
+ Plugin.logger.LogError("Multiplayer> Error during HandleCreateLobbyModded: " + ex.Message);
+ tcs.SetException(new Il2CppSystem.Exception(ex.Message));
+ }
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.StartLobbyGame))]
+ private static bool BackendAdapter_StartLobbyGame_Modded(
+ ref Il2CppSystem.Threading.Tasks.Task> __result,
+ BackendAdapter __instance,
+ StartLobbyBindingModel model)
+ {
+ // On Android, let the game's original StartLobbyGame handle it
+ if (Application.platform == RuntimePlatform.Android) return true;
+
+ 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 Settings 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));
+ }
+}
\ No newline at end of file
diff --git a/src/Multiplayer/Multiplayer.cs b/src/Multiplayer/Multiplayer.cs
new file mode 100644
index 0000000..1520742
--- /dev/null
+++ b/src/Multiplayer/Multiplayer.cs
@@ -0,0 +1,208 @@
+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;
+using PolytopiaBackendBase.Auth;
+
+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;
+
+ // Update BackendUri and HttpClient.BaseAddress since PolytopiaBackendAdapter.Instance
+ // was statically initialized before plugins load, so it still points to polytopia-prod.net
+ var uri = new Il2CppSystem.Uri(Plugin.config.backendUrl);
+ PolytopiaBackendAdapter.Instance.UseBackendUri(uri);
+ PolytopiaBackendAdapter.Instance.BackendHttpClient.BaseAddress = uri;
+
+ Plugin.logger.LogInfo($"Multiplayer> Server URL set to: {Plugin.config.backendUrl}");
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(MultiplayerSelectionScreen), nameof(MultiplayerSelectionScreen.Awake))]
+ public static void MultiplayerSelectionScreen_Awake(MultiplayerSelectionScreen __instance)
+ {
+ __instance.TournamentsButton.gameObject.SetActive(false);
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))]
+ private static void StartScreen_UI2_HideButtons(StartScreen_UI2 __instance)
+ {
+ __instance.highscoreButton.gameObject.SetActive(false);
+ __instance.weeklyChallengeButton.gameObject.SetActive(false);
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))]
+ private static void StartScreen_UI2_ReflowRoundButtons(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize)
+ {
+ // RunLayout adds all four round buttons to a UITable unconditionally, so hiding the highscore button leaves a gap. Re-run the row without it so the rest recenter.
+ UITable table = new();
+ table.AddCell(__instance.settingsButton.Cast());
+ table.AddCell(__instance.throneRoomButton.Cast());
+ table.AddCell(__instance.aboutButton.Cast());
+ table.SetBottom(screenSize.safeRect.Bottom + __instance.settingsButton.GetHalfHeight() + 15f);
+ table.margin = 20f;
+ table.RunLayout();
+ }
+
+ [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))]
+ [Obsolete("This will be succeeded by ModMultiplayer in the future.")]
+ 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
+ ///
+ [Obsolete("This will be succeeded by ModMultiplayer in the future.")]
+ 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;
+ }
+}
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..41274bd
--- /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; } = string.Empty;
+
+ public byte[] serializedGameState { get; set; } = Array.Empty();
+
+ public string gameSettingsJson { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/Plugin.cs b/src/Plugin.cs
index 96ae942..ee514f8 100644
--- a/src/Plugin.cs
+++ b/src/Plugin.cs
@@ -3,7 +3,9 @@
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
+using PolyMod.Android;
using PolyMod.Managers;
+using PolyMod.Multiplayer;
using UnityEngine;
namespace PolyMod;
@@ -24,7 +26,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 = ""
);
///
@@ -115,7 +119,7 @@ public override void Load()
}
catch
{
- config = new();
+ config = new(backendUrl: Multiplayer.Dystopia.DefaultServerUrl());
}
WriteConfig();
UpdateConsole();
@@ -132,6 +136,10 @@ public override void Load()
Hub.Init();
Main.Init();
+ Client.Init();
+ ModMultiplayer.Init();
+ Dystopia.Init();
+ AndroidHandler.Init();
}
///
@@ -163,8 +171,9 @@ internal static void UpdateConsole()
{
ConsoleManager.CreateConsole();
}
- else
+ else if (OperatingSystem.IsWindows())
{
+ // BepInEx's Unix console driver throws unsupported on detach. Off-Windows there is no separate console window, so there is nothing to detach.
ConsoleManager.DetachConsole();
}
}