Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -406,4 +406,6 @@ dist/

# End of https://www.toptal.com/developers/gitignore/api/csharp

run.bat
run.bat

.idea/
2 changes: 1 addition & 1 deletion PolyMod.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</RestoreAdditionalProjectSources>
<Configurations>IL2CPP</Configurations>
<RootNamespace>PolyMod</RootNamespace>
<Version>1.2.17</Version>
<Version>1.3.0-pre-android-multi-1</Version>
<PolytopiaVersion>2.17.2.16299</PolytopiaVersion>
<Authors>PolyModdingTeam</Authors>
<Description>The Battle of Polytopia's mod loader.</Description>
Expand Down
Binary file added resources/dystopia_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions resources/localization.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,44 @@
{
"polymod_dystopia": {
"English": "Dystopia",
"German (Germany)": "Dystopia"
},
"polymod_dystopia_server": {
"English": "Server",
"German (Germany)": "Server"
},
"polymod_dystopia_connected": {
"English": "connected",
"German (Germany)": "verbunden"
},
"polymod_dystopia_disconnected": {
"English": "disconnected",
"German (Germany)": "getrennt"
},
"polymod_dystopia_modded": {
"English": "modded",
"German (Germany)": "gemoddet"
},
"polymod_dystopia_vanilla": {
"English": "vanilla",
"German (Germany)": "vanilla"
},
"polymod_dystopia_modded_description": {
"English": "Gameplay Mods active\nChecksum: {0}\n\nOnly players with the exact same mods can join your games. The official backend is unavailable while gameplay mods are loaded.",
"German (Germany)": "Gameplay-Mods aktiv\nPrüfsumme: {0}\n\nNur Spieler mit exakt denselben Mods können deinen Spielen beitreten. Der offizielle Server ist mit geladenen Gameplay-Mods nicht verfügbar."
},
"polymod_dystopia_vanilla_description": {
"English": "No gameplay mods loaded — vanilla multiplayer.",
"German (Germany)": "Keine Gameplay-Mods geladen — Vanilla-Multiplayer."
},
"polymod_dystopia_connect": {
"English": "Connect to {0}",
"German (Germany)": "Mit {0} verbinden"
},
"polymod_dystopia_custom": {
"English": "Custom server...",
"German (Germany)": "Eigener Server..."
},
"polymod_hub": {
"English": "PolyMod Hub",
"Russian": "Центр PolyMod",
Expand Down
90 changes: 90 additions & 0 deletions src/Android/AndroidHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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));
}

/// <summary>
/// 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.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(GameManager), nameof(GameManager.IsMultiplayerEnabled), MethodType.Getter)]
public static bool GameManager_IsMultiplayerEnabled(ref bool __result)
{
__result = true;
return false;
}

/// <summary>
/// Replace the Android login flow to skip Google Play Games SDK entirely.
/// Uses deviceUniqueIdentifier as the auth code for the Polydystopia backend.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(PolytopiaBackendAdapter), "LoginPlatformAndroid")]
public static bool LoginPlatformAndroid_Prefix(
ref Il2CppSystem.Threading.Tasks.Task<ServerResponse<PolytopiaToken>> __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;
model.GameVersion = new Il2CppSystem.Nullable<int>(VersionManager.GameVersion);

Plugin.logger.LogInfo($"Multiplayer> Android login with DeviceId: {model.DeviceId}");
__result = __instance.LoginGooglePlay(model);
return false;
}

/// <summary>
/// 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.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(AnalyticsManager), nameof(AnalyticsManager.IsAnalyticsEnabled))]
private static bool AnalyticsManager_IsAnalyticsEnabled(ref bool __result)
{
__result = false;
return false;
}

/// <summary>
/// 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).
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(FirebaseMessagingManager), nameof(FirebaseMessagingManager.Init))]
private static bool FirebaseMessagingManager_Init()
{
return false;
}

/// <summary>
/// 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.
/// </summary>
[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;
}
}
29 changes: 29 additions & 0 deletions src/Managers/Compatibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ internal static class Compatibility
internal static bool shouldResetSettings = false;
private static bool sawSignatureWarning;

/// <summary>
/// Whether all loaded mods are client only. If at least one non client only mod exists this returns false.
/// </summary>
/// <returns></returns>
public static bool IsClientOnly()
{
return Registry.mods.Select(modPair => modPair.Value)
.All(mod => mod.client || mod.id == "polytopia" || mod.status != Mod.Status.Success);
}

/// <summary>
/// Hashes the signatures of all loaded mods to create a checksum.
/// </summary>
Expand Down Expand Up @@ -152,6 +162,25 @@ private static bool StartScreen_OnResumeButtonClick(StartScreen_UI2 __instance)
return CheckSignatures(__instance.OnResumeButtonLongPress, LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)[0]);
}

/// <summary>
/// Checks the signature of a multiplayer game before opening it.
/// Blocks on mismatch.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(GameManager), nameof(GameManager.OpenMultiplayerGame))]
private static bool GameManager_OpenMultiplayerGame(
ref Il2CppSystem.Threading.Tasks.Task<bool> __result,
Il2CppSystem.Guid gameId)
{
if (CheckSignatures(null!, gameId)) return true;

var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource<bool>();
taskCompletionSource.SetResult(false);
__result = taskCompletionSource.Task;

return false;
}

/// <summary>
/// Deletes the signature file of a pass-and-play game when it is deleted.
/// </summary>
Expand Down
94 changes: 92 additions & 2 deletions src/Managers/Visual.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ public record SkinInfo(int idx, string id, SkinData? skinData);

/// <summary>A dictionary of custom widths for basic popups.</summary>
public static Dictionary<int, int> basicPopupWidths = new();
/// <summary>Original font sizes of popup buttons, so relayouts rescale from the prefab size.</summary>
private static readonly Dictionary<int, float> popupButtonFontSizes = new();
/// <summary>Original scroll viewport bottom offsets of popups, so relayouts don't compound.</summary>
private static readonly Dictionary<int, float> popupScrollBottoms = new();
/// <summary>Represents information about a unit prefab.</summary>
public struct UnitPrefabInfo
{
Expand Down Expand Up @@ -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<BasicPopupLegacy>();
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<List<UITextButton>> 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<RectTransform>();
int popupId = popup.GetInstanceID();
if (!popupScrollBottoms.ContainsKey(popupId))
popupScrollBottoms[popupId] = viewport.offsetMin.y;
viewport.offsetMin = new Vector2(viewport.offsetMin.x, popupScrollBottoms[popupId] + extra);
}
}

/// <summary>Sets the attacker's tribe before a unit attacks.</summary>
Expand Down Expand Up @@ -809,12 +892,19 @@ private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType)
}
}

/// <summary>Removes a popup's custom width when it is hidden.</summary>
/// <summary>Removes a popup's custom width and cached button font sizes when it is hidden.</summary>
[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<BasicPopupLegacy>();
if (legacy != null && legacy.buttonContainer != null && legacy.buttonContainer.Buttons != null)
{
foreach (UITextButton button in legacy.buttonContainer.Buttons)
popupButtonFontSizes.Remove(button.GetInstanceID());
}
}

[HarmonyPrefix]
Expand Down
Loading
Loading