diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..bca1bc2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,45 @@
+root = true
+
+[*]
+charset = utf-8
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{json,yml,yaml}]
+indent_style = space
+indent_size = 2
+
+[*.cs]
+indent_style = tab
+tab_width = 4
+indent_size = 4
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_style_var_when_type_is_apparent = true:suggestion
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+csharp_prefer_braces = when_multiline:suggestion
+dotnet_diagnostic.IDE0005.severity = warning
+dotnet_diagnostic.CS4014.severity = warning
+dotnet_diagnostic.IDE0051.severity = none
+dotnet_diagnostic.IDE0060.severity = none
+dotnet_diagnostic.CA1707.severity = none
+dotnet_diagnostic.CA1051.severity = none
+dotnet_diagnostic.CA2211.severity = none
+dotnet_diagnostic.CA1805.severity = suggestion
+dotnet_diagnostic.CA1716.severity = none
+dotnet_diagnostic.CA1708.severity = none
+dotnet_diagnostic.CS1591.severity = none
+dotnet_diagnostic.CS1573.severity = none
+
+[tests/**.cs]
+dotnet_diagnostic.CA1861.severity = none
diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml
deleted file mode 100644
index f75f773..0000000
--- a/.github/workflows/installer.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Installer
-on:
- push:
- paths:
- - 'installer/**'
- pull_request:
- paths:
- - 'installer/**'
-jobs:
- job:
- defaults:
- run:
- working-directory: ./installer
- strategy:
- fail-fast: false
- matrix:
- os: [windows-latest, ubuntu-22.04, macos-13]
- name: Build on ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
- with:
- python-version: '3.11'
- - name: Install requirements
- run: pip install -r requirements.txt
- - name: Build
- run: python build.py
- - uses: actions/upload-artifact@v4
- with:
- name: ${{ matrix.os }}
- path: installer/dist/
- merge:
- name: Merge artifacts
- runs-on: ubuntu-latest
- needs: job
- steps:
- - uses: actions/upload-artifact/merge@v4
- with:
- name: artifacts
- delete-merged: true
- separate-directories: true
-
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..66f37af
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,14 @@
+
+
+
+ true
+ latest
+ Recommended
+ true
+
+
+ true
+ $(NoWarn);CS1591
+
+
+
diff --git a/README.md b/README.md
index 2772d50..408cd22 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
| | x86 | ARM |
|:-------:|:---:|:---:|
| Windows | ✅ | ❌ |
-| Linux | ⏺️ | ❌ |
-| MacOS | ⏺️ | ❌ |
+| Linux | ✅ | ❌ |
+| MacOS | ❌ | ❌ |
| Android | ➖ | 🚧 |
| IOS | ➖ | ❌ |
diff --git a/installer/build.py b/installer/build.py
deleted file mode 100644
index 8b802ae..0000000
--- a/installer/build.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import os
-import PyInstaller.__main__
-
-ROOT = os.path.abspath(os.getcwd())
-
-PyInstaller.__main__.run(
- [
- "--noconfirm",
- "--onefile",
- "--windowed",
- "--icon",
- ROOT + "/icon.ico",
- "--name",
- "PolyMod",
- "--add-data",
- ROOT + "/icon.ico:.",
- ROOT + "/main.py",
- ]
-)
diff --git a/installer/icon.ico b/installer/icon.ico
deleted file mode 100644
index f52643a..0000000
Binary files a/installer/icon.ico and /dev/null differ
diff --git a/installer/main.py b/installer/main.py
deleted file mode 100644
index e1fad1b..0000000
--- a/installer/main.py
+++ /dev/null
@@ -1,154 +0,0 @@
-import os
-import io
-import sys
-import shutil
-import zipfile
-import requests
-import threading
-import subprocess
-import customtkinter
-import CTkMessagebox as messagebox
-
-OS = {
- "linux": "linux",
- "linux2": "linux",
- "win32": "win",
- "darwin": "macos",
-}[sys.platform]
-BEPINEX = "https://polymod.dev/data/bepinex.txt"
-POLYMOD = "https://api.github.com/repos/PolyModdingTeam/PolyMod/releases"
-
-
-def resource_path(path):
- try:
- base_path = sys._MEIPASS
- except Exception:
- base_path = os.path.abspath(".")
- return os.path.join(base_path, path)
-
-
-def to_zip(request: requests.Response):
- return zipfile.ZipFile(io.BytesIO(request.content))
-
-
-def browse():
- global path_entry
- path_entry.delete(0, customtkinter.END)
- path_entry.insert(0, customtkinter.filedialog.askdirectory())
-
-
-def prepare(target):
- global progress_bar
- path = path_entry.get()
- try:
- if "Polytopia_Data" not in os.listdir(path):
- raise FileNotFoundError
- except FileNotFoundError:
- messagebox.CTkMessagebox(
- title="Error",
- message="The folder does not exist or is not valid!",
- icon="cancel",
- width=100,
- height=50
- )
- return
- path_entry.configure(state=customtkinter.DISABLED)
- browse_button.configure(state=customtkinter.DISABLED)
- prerelease_checkbox.destroy()
- install_button.destroy()
- uninstall_button.destroy()
- progress_bar = customtkinter.CTkProgressBar(app, determinate_speed=50 / 2)
- progress_bar.grid(column=0, row=1, columnspan=2, padx=5, pady=5)
- progress_bar.set(0)
- threading.Thread(target=target, daemon=True, args=(path, )).start()
-
-
-def install(path):
- to_zip(
- requests.get(
- requests.get(BEPINEX).text.strip().replace("{os}", OS)
- )
- ).extractall(path)
- progress_bar.step()
-
- for release in requests.get(POLYMOD).json():
- if release["prerelease"] and not prerelease_checkbox.get(): continue
- latest = release
- break
- open(path + "/BepInEx/plugins/PolyMod.dll", "wb").write(
- requests.get(latest["assets"][0]["browser_download_url"]).content
- )
- progress_bar.step()
-
- customtkinter.CTkButton(app, text="Launch", command=lambda: launch(path)).grid(
- column=0, row=2, columnspan=2, padx=5, pady=5
- )
-
-
-def uninstall(path):
- dirs = [
- "BepInEx",
- "dotnet",
- ]
- files = [
- ".doorstop_version",
- "changelog.txt",
- "doorstop_config.ini",
- "winhttp.dll", # windows
- "libdoorstop.so", # linux
- "libdoorstop.dylib", # mac
- "run_bepinex.sh", # linux + mac
- ]
- for dir in dirs:
- shutil.rmtree(path + "/" + dir, True)
- progress_bar.step()
- for file in files:
- try:
- os.remove(path + "/" + file)
- except FileNotFoundError:
- ...
- progress_bar.step()
- customtkinter.CTkButton(app, text="Quit", command=quit).grid(
- column=0, row=2, columnspan=2, padx=5, pady=5
- )
-
-
-def launch(path):
- if sys.platform != "win32":
- subprocess.check_call(f"chmod +x {path}/run_bepinex.sh", shell=True)
- subprocess.check_call(f"{path}/run_bepinex.sh {path}/Polytopia.*", shell=True)
- subprocess.check_call(f"xdg-open https://docs.bepinex.dev/articles/advanced/steam_interop.html", shell=True)
- else:
- subprocess.check_call(f"start steam://rungameid/874390", shell=True)
- quit()
-
-
-def quit():
- app.destroy()
- sys.exit()
-
-
-app = customtkinter.CTk()
-app.title("PolyMod")
-if OS != "linux":
- app.iconbitmap(default=resource_path("icon.ico"))
-app.resizable(False, False)
-
-path_entry = customtkinter.CTkEntry(
- app, placeholder_text="Game path", width=228)
-browse_button = customtkinter.CTkButton(
- app, text="Browse", command=browse, width=1)
-prerelease_checkbox = customtkinter.CTkCheckBox(
- app, text="Prerelease", width=1)
-install_button = customtkinter.CTkButton(
- app, text="Install", command=lambda: prepare(install))
-uninstall_button = customtkinter.CTkButton(
- app, text="Uninstall", command=lambda: prepare(uninstall))
-
-path_entry.grid(column=0, row=0, padx=5, pady=5)
-browse_button.grid(column=1, row=0, padx=(0, 5), pady=5)
-prerelease_checkbox.grid(column=0, row=1, columnspan=2, padx=5, pady=5)
-install_button.grid(column=0, row=2, columnspan=2, padx=5, pady=5)
-uninstall_button.grid(column=0, row=3, columnspan=2, padx=5, pady=5)
-
-app.mainloop()
diff --git a/installer/requirements.txt b/installer/requirements.txt
deleted file mode 100644
index c396099..0000000
--- a/installer/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-requests
-pyinstaller
-customtkinter
-CTkMessagebox
\ No newline at end of file
diff --git a/resources/localization.json b/resources/localization.json
index 3b19f1e..1a97e23 100644
--- a/resources/localization.json
+++ b/resources/localization.json
@@ -60,9 +60,13 @@
"German (Germany)": "DATENDUMP"
},
"polymod_hub_dumped": {
- "English": "Dump completed successfully!",
- "Russian": "Дамп завершен успешно!",
- "German (Germany)": "Dump erfolgreich beendet!"
+ "English": "Dump completed successfully!\n\n{0}",
+ "Russian": "Дамп завершен успешно!\n\n{0}",
+ "German (Germany)": "Dump erfolgreich beendet!\n\n{0}"
+ },
+ "polymod_hub_openfolder": {
+ "English": "OPEN FOLDER",
+ "Russian": "ОТКРЫТЬ ПАПКУ"
},
"polymod_cycle": {
"English": "Dependency cycle detected",
@@ -218,29 +222,83 @@
"Russian": "{0} задан на {1}!",
"Elyrion": "{0} iȓ ȓ∑Ŧ Ŧȱ {1}!"
},
- "polymod_hub_spriteinfo_update": {
- "English": "UPDATE SPRITES",
- "Russian": "ОБНОВИТЬ СПРАЙТЫ",
- "Elyrion": "₺πΔaŦ∑ ȓπriŦ∑ȓ"
+ "polymod_hub_hotreload": {
+ "English": "HOT RELOAD"
},
- "polymod_spriteinfo_updated": {
- "English": "Sprite info for mod {0} updated!",
- "Russian": "Данные спрайтов обновлены для мода {0}!",
- "Elyrion": "ȓπriŦ∑ iŋ₼ȱ ₼ȱr mȱΔ {0} ₺πΔaŦ∑Δ!"
+ "polymod_hotreload_success": {
+ "English": "Hot reload completed for the mod {0}!"
},
- "polymod_spriteinfo_notupdated": {
- "English": "No sprite infos were found!",
+ "polymod_hotreload_failure": {
+ "English": "No mods for hot reload!",
"Russian": "Данные спрайтов не найдены!",
"Elyrion": "ŋȱ ȓπriŦ∑ iŋ₼ȱȓ ~∑r∑ ₼ȱ₺ŋΔ!"
},
- "polymod_wc_warning": {
- "English": "{0} may not work correctly due to PolyMod. Do you wish to proceed?",
- "Russian": "{0} может работать некорректно из-за PolyMod'а. Вы уверены что хотите продолжить?",
- "Elyrion": "{0} maỹ ŋȱŦ ~ȱr§ #ȱrr∑#Ŧ∫ỹ Δ₺∑ Ŧȱ πȱ∫ỹmȱΔ. Δȱ ỹȱ₺ ~iȓţ Ŧȱ πrȱ#∑∑Δ?"
+ "polymod_wc_vanillaonly": {
+ "English": "{0} is only available in vanilla Polytopia. Disable PolyMod in the launcher, or start the game without the launcher.",
+ "Russian": "{0} доступен только в ванильной Polytopia. Отключите PolyMod в лаунчере или запустите игру без лаунчера."
+ },
+ "polymod_title_invalid": {
+ "English": "Invalid Launch",
+ "Russian": "Неверный Запуск"
+ },
+ "polymod_description_invalid": {
+ "English": "PolyMod launch was invalid. Please, open the game via the PolyMod Launcher or proceed to our site to install it :)",
+ "Russian": "PolyMod запущен неккоректно. Пожалуйста, откройте игру через PolyMod Launcher или перейдите на наш сайт и установите его :)"
+ },
+ "buttons_download_launcher": {
+ "English": "OUR SITE",
+ "Russian": "НАШ САЙТ"
+ },
+ "polymod_hub_summary": {
+ "English": "{0} of {1} mods loaded.",
+ "Russian": "Загружено модов: {0} из {1}."
+ },
+ "polymod_hub_mods_none": {
+ "English": "No mods are installed yet. Put mod archives into the Mods folder next to the game and restart it.",
+ "Russian": "Моды пока не установлены. Положите архивы модов в папку Mods рядом с игрой и перезапустите её."
+ },
+ "polymod_hub_mod_authors": {
+ "English": "by {0}",
+ "Russian": "от {0}"
+ },
+ "polymod_hub_mod_checksum": {
+ "English": "checksum {0}",
+ "Russian": "контрольная сумма {0}"
+ },
+ "polymod_hub_checksum": {
+ "English": "Global checksum: {0}",
+ "Russian": "Общая контрольная сумма: {0}"
+ },
+ "polymod_hub_config_debug": {
+ "English": "DEBUG",
+ "Russian": "ОТЛАДКА"
+ },
+ "polymod_hub_config_coordinates": {
+ "English": "COORDINATES",
+ "Russian": "КООРДИНАТЫ"
+ },
+ "polymod_debug_description": {
+ "English": "Opens the developer console and writes verbose logs.",
+ "Russian": "Открывает консоль разработчика и включает подробные логи."
+ },
+ "polymod_coordinates": {
+ "English": "Coordinates",
+ "Russian": "Координаты"
+ },
+ "polymod_coordinates_description": {
+ "English": "Shows the coordinates of every tile on the map.",
+ "Russian": "Показывает координаты каждой клетки на карте."
+ },
+ "polymod_on": {
+ "English": "ON",
+ "Russian": "ВКЛ"
+ },
+ "polymod_off": {
+ "English": "OFF",
+ "Russian": "ВЫКЛ"
},
- "polymod_wc_proceed": {
- "English": "PROCEED",
- "Russian": "ПРОДОЛЖИТЬ",
- "Elyrion": "πrȱ#∑∑Δ"
+ "polymod_hotreload_unavailable": {
+ "English": "Hot reload is not available yet.",
+ "Russian": "Горячая перезагрузка пока недоступна."
}
-}
+}
\ No newline at end of file
diff --git a/src/Api/Config.cs b/src/Api/Config.cs
index f253bf8..4cd3a19 100644
--- a/src/Api/Config.cs
+++ b/src/Api/Config.cs
@@ -8,113 +8,114 @@ namespace PolyMod.Api;
///
public class Config where T : class
{
- private T? currentConfig;
- private readonly string modName;
- private readonly ConfigTypes configType;
- private static readonly string ExposedConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json");
- private readonly string perModConfigPath;
- private T? defaultConfig;
- public Config(string modName, ConfigTypes configType)
- {
- this.modName = modName;
- this.configType = configType;
- perModConfigPath = Path.Combine(Plugin.MODS_PATH, $"{modName}.json");
- Load();
- }
+ private static readonly JsonSerializerOptions indentedJsonOptions = new() { WriteIndented = true };
+ private T? currentConfig;
+ private readonly string modName;
+ private readonly ConfigTypes configType;
+ private static readonly string ExposedConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json");
+ private readonly string perModConfigPath;
+ private T? defaultConfig;
+ public Config(string modName, ConfigTypes configType)
+ {
+ this.modName = modName;
+ this.configType = configType;
+ perModConfigPath = Path.Combine(Plugin.MODS_PATH, $"{modName}.json");
+ Load();
+ }
- internal void Load() // can be called internally if config changes; gui config not implemented yet
- {
- switch (configType)
- {
- case ConfigTypes.PerMod:
- {
- if (!File.Exists(perModConfigPath))
- {
- return;
- }
- var jsonText = File.ReadAllText(perModConfigPath);
- currentConfig = JsonSerializer.Deserialize(jsonText);
- break;
- }
- case ConfigTypes.Exposed:
- {
- if (!File.Exists(ExposedConfigPath))
- {
- return;
- }
- var jsonText = File.ReadAllText(ExposedConfigPath);
- currentConfig = JsonNode.Parse(jsonText)![modName]?.Deserialize();
- break;
- }
- default:
- throw new ArgumentOutOfRangeException();
- }
- }
- ///
- /// Sets the default if the config does not exist yet. Always call this before reading from the config.
- ///
- public void SetDefaultConfig(T defaultValue)
- {
- defaultConfig = defaultValue;
- if (currentConfig is not null) return;
- Write(defaultConfig);
- SaveChanges();
- }
+ internal void Load() // can be called internally if config changes; gui config not implemented yet
+ {
+ switch (configType)
+ {
+ case ConfigTypes.PerMod:
+ {
+ if (!File.Exists(perModConfigPath))
+ {
+ return;
+ }
+ var jsonText = File.ReadAllText(perModConfigPath);
+ currentConfig = JsonSerializer.Deserialize(jsonText);
+ break;
+ }
+ case ConfigTypes.Exposed:
+ {
+ if (!File.Exists(ExposedConfigPath))
+ {
+ return;
+ }
+ var jsonText = File.ReadAllText(ExposedConfigPath);
+ currentConfig = JsonNode.Parse(jsonText)![modName]?.Deserialize();
+ break;
+ }
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+ ///
+ /// Sets the default if the config does not exist yet. Always call this before reading from the config.
+ ///
+ public void SetDefaultConfig(T defaultValue)
+ {
+ defaultConfig = defaultValue;
+ if (currentConfig is not null) return;
+ Write(defaultConfig);
+ SaveChanges();
+ }
- ///
- /// Writes the **entire** config. Usage not recommended, use Edit() instead
- ///
- public void Write(T config)
- {
- currentConfig = config;
- }
- ///
- /// Gets the config. Should only be called after setting a default.
- ///
- public T Get()
- {
- return currentConfig ?? throw new InvalidOperationException("Must set default before reading config.");
- }
- ///
- /// Edits the config. Should only be called after setting a default.
- ///
- /// Call SaveChanges after editing
- public void Edit(Action editor)
- {
- editor(currentConfig ?? throw new InvalidOperationException("Must set default before reading config."));
- }
- ///
- /// Gets part of the config. Should only be called after setting a default
- ///
- public TResult Get(Func getter)
- {
- return getter(currentConfig ?? throw new InvalidOperationException("Must set default before reading config."));
- }
- ///
- /// Writes the config to disk
- ///
- public void SaveChanges()
- {
- switch (configType)
- {
- case ConfigTypes.PerMod:
- var perModJson = JsonSerializer.Serialize(currentConfig, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(perModConfigPath, perModJson);
- break;
- case ConfigTypes.Exposed:
- var modsConfigText = File.ReadAllText(ExposedConfigPath);
- var modsConfigJson = JsonNode.Parse(modsConfigText)!.AsObject();
- modsConfigJson[modName] = JsonSerializer.SerializeToNode(currentConfig!);
- File.WriteAllText(ExposedConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
- break;
- default:
- throw new ArgumentOutOfRangeException();
- }
- }
+ ///
+ /// Writes the **entire** config. Usage not recommended, use Edit() instead
+ ///
+ public void Write(T config)
+ {
+ currentConfig = config;
+ }
+ ///
+ /// Gets the config. Should only be called after setting a default.
+ ///
+ public T Get()
+ {
+ return currentConfig ?? throw new InvalidOperationException("Must set default before reading config.");
+ }
+ ///
+ /// Edits the config. Should only be called after setting a default.
+ ///
+ /// Call SaveChanges after editing
+ public void Edit(Action editor)
+ {
+ editor(currentConfig ?? throw new InvalidOperationException("Must set default before reading config."));
+ }
+ ///
+ /// Gets part of the config. Should only be called after setting a default
+ ///
+ public TResult Get(Func getter)
+ {
+ return getter(currentConfig ?? throw new InvalidOperationException("Must set default before reading config."));
+ }
+ ///
+ /// Writes the config to disk
+ ///
+ public void SaveChanges()
+ {
+ switch (configType)
+ {
+ case ConfigTypes.PerMod:
+ var perModJson = JsonSerializer.Serialize(currentConfig, indentedJsonOptions);
+ File.WriteAllText(perModConfigPath, perModJson);
+ break;
+ case ConfigTypes.Exposed:
+ var modsConfigText = File.ReadAllText(ExposedConfigPath);
+ var modsConfigJson = JsonNode.Parse(modsConfigText)!.AsObject();
+ modsConfigJson[modName] = JsonSerializer.SerializeToNode(currentConfig!);
+ File.WriteAllText(ExposedConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
- public enum ConfigTypes
- {
- PerMod,
- Exposed
- }
-}
\ No newline at end of file
+ public enum ConfigTypes
+ {
+ PerMod,
+ Exposed
+ }
+}
diff --git a/src/Api/GLDConfig.cs b/src/Api/GLDConfig.cs
index ecda511..8ff0e62 100644
--- a/src/Api/GLDConfig.cs
+++ b/src/Api/GLDConfig.cs
@@ -7,88 +7,88 @@ namespace PolyMod.Api;
public class GldConfigTemplate
{
- private static readonly string ConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json");
+ private static readonly string ConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json");
- private readonly string templateText;
- private JsonObject currentConfig = new();
- private string modName;
+ private readonly string templateText;
+ private JsonObject currentConfig = new();
+ private string modName;
- public GldConfigTemplate(string templateText, string modName)
- {
- this.templateText = templateText;
- this.modName = modName;
- Load();
- }
- private void Load()
- {
- if (File.Exists(ConfigPath))
- {
- var json = File.ReadAllText(ConfigPath);
- if (JsonNode.Parse(json) is JsonObject modsConfig
- && modsConfig.TryGetPropertyValue(modName, out var modConfigNode)
- && modConfigNode is JsonObject modConfig)
- {
- currentConfig = modConfig;
- return;
- }
- }
- currentConfig = new JsonObject();
- }
+ public GldConfigTemplate(string templateText, string modName)
+ {
+ this.templateText = templateText;
+ this.modName = modName;
+ Load();
+ }
+ private void Load()
+ {
+ if (File.Exists(ConfigPath))
+ {
+ var json = File.ReadAllText(ConfigPath);
+ if (JsonNode.Parse(json) is JsonObject modsConfig
+ && modsConfig.TryGetPropertyValue(modName, out var modConfigNode)
+ && modConfigNode is JsonObject modConfig)
+ {
+ currentConfig = modConfig;
+ return;
+ }
+ }
+ currentConfig = new JsonObject();
+ }
- public string? Render()
- {
- if (!templateText.Contains("{{")) return templateText;
- var template = Template.Parse(templateText);
- var context = new TemplateContext();
- var scriptObject = new ScriptObject();
+ public string? Render()
+ {
+ if (!templateText.Contains("{{")) return templateText;
+ var template = Template.Parse(templateText);
+ var context = new TemplateContext();
+ var scriptObject = new ScriptObject();
- bool changedConfig = false;
- scriptObject.Import("config",
- new Func((key, defaultValue) =>
- {
- if (currentConfig.TryGetPropertyValue(key, out var token) && token != null)
- {
- return token.ToString();
- }
+ bool changedConfig = false;
+ scriptObject.Import("config",
+ new Func((key, defaultValue) =>
+ {
+ if (currentConfig.TryGetPropertyValue(key, out var token) && token != null)
+ {
+ return token.ToString();
+ }
- changedConfig = true;
- currentConfig[key] = defaultValue;
+ changedConfig = true;
+ currentConfig[key] = defaultValue;
- return defaultValue;
- })
- );
- context.PushGlobal(scriptObject);
- string? result;
- try
- {
- result = template.Render(context);
- }
- catch (Exception e)
- {
- Plugin.logger.LogError("error during parse of gld patch template: " + e.ToString());
- result = null;
- }
- if (changedConfig)
- {
- SaveChanges();
- }
- return result;
- }
+ return defaultValue;
+ })
+ );
+ context.PushGlobal(scriptObject);
+ string? result;
+ try
+ {
+ result = template.Render(context);
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError("error during parse of gld patch template: " + e.ToString());
+ result = null;
+ }
+ if (changedConfig)
+ {
+ SaveChanges();
+ }
+ return result;
+ }
- public void SaveChanges()
- {
- JsonObject modsConfigJson;
- if (File.Exists(ConfigPath))
- {
- var modsConfigText = File.ReadAllText(ConfigPath);
- modsConfigJson = (JsonNode.Parse(modsConfigText) as JsonObject) ?? new JsonObject();
- }
- else
- {
- modsConfigJson = new JsonObject();
- }
+ public void SaveChanges()
+ {
+ JsonObject modsConfigJson;
+ if (File.Exists(ConfigPath))
+ {
+ var modsConfigText = File.ReadAllText(ConfigPath);
+ modsConfigJson = (JsonNode.Parse(modsConfigText) as JsonObject) ?? new JsonObject();
+ }
+ else
+ {
+ modsConfigJson = new JsonObject();
+ }
- modsConfigJson[modName] = currentConfig;
- File.WriteAllText(ConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
- }
-}
\ No newline at end of file
+ modsConfigJson[modName] = currentConfig;
+ File.WriteAllText(ConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ }
+}
diff --git a/src/Api/PolyScript.cs b/src/Api/PolyScript.cs
index 09fd36e..6dd697e 100644
--- a/src/Api/PolyScript.cs
+++ b/src/Api/PolyScript.cs
@@ -5,50 +5,50 @@ namespace PolyMod.Api;
public abstract class PolyScriptBase
{
- ///
- /// Initializes the PolyScript with the given mod and logger.
- ///
- /// The mod instance.
- /// The logger instance.
- internal abstract void Initialize(Mod mod, ManualLogSource logger);
-
- /// >
- /// Called when the mod is loaded.
- ///
- public abstract void Load();
-
- ///
- /// Called when the mod is unloaded.
- ///
- public abstract void Unload();
-
- internal PolyScriptBase()
- {
- }
+ ///
+ /// Initializes the PolyScript with the given mod and logger.
+ ///
+ /// The mod instance.
+ /// The logger instance.
+ internal abstract void Initialize(Mod mod, ManualLogSource logger);
+
+ /// >
+ /// Called when the mod is loaded.
+ ///
+ public abstract void Load();
+
+ ///
+ /// Called when the mod is unloaded.
+ ///
+ public abstract void Unload();
+
+ internal PolyScriptBase()
+ {
+ }
}
public abstract class PolyScript : PolyScriptBase where TConfig : class where TExposedConfig : class
{
- internal override void Initialize(Mod mod, ManualLogSource logger)
- {
- Mod = mod;
- Config = new Config(mod.id, Config.ConfigTypes.PerMod);
- ExposedConfig = new Config(mod.id, Config.ConfigTypes.Exposed);
- Logger = logger;
- }
-
- internal byte[]? GetFile(string fileName)
- {
- return Registry.mods[Mod.id].files.FirstOrDefault(f => f.name == fileName)?.bytes;
- }
-
- public override void Unload() { }
-
- public Mod Mod { get; private set; } = null!;
- protected Config Config { get; private set; } = null!;
- protected Config ExposedConfig { get; private set; } = null!;
- protected ManualLogSource Logger { get; private set; } = null!;
+ internal override void Initialize(Mod mod, ManualLogSource logger)
+ {
+ Mod = mod;
+ Config = new Config(mod.id, Config.ConfigTypes.PerMod);
+ ExposedConfig = new Config(mod.id, Config.ConfigTypes.Exposed);
+ Logger = logger;
+ }
+
+ internal byte[]? GetFile(string fileName)
+ {
+ return Registry.mods[Mod.id].files.FirstOrDefault(f => f.name == fileName)?.bytes;
+ }
+
+ public override void Unload() { }
+
+ public Mod Mod { get; private set; } = null!;
+ protected Config Config { get; private set; } = null!;
+ protected Config ExposedConfig { get; private set; } = null!;
+ protected ManualLogSource Logger { get; private set; } = null!;
}
public abstract class PolyScript : PolyScript
{
-}
\ No newline at end of file
+}
diff --git a/src/Dumper.cs b/src/Dumper.cs
new file mode 100644
index 0000000..0bd3c20
--- /dev/null
+++ b/src/Dumper.cs
@@ -0,0 +1,291 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Il2CppSystem.Linq;
+using PolyMod.Json;
+using PolytopiaBackendBase.Common;
+using UnityEngine;
+
+namespace PolyMod.Managers;
+
+///
+/// Writes the game's own data (logic, localization, sprites, tribe previews and prefabs) to disk
+/// so that mod authors can use it as a reference.
+///
+internal static class Dumper
+{
+ private static readonly JsonSerializerOptions INDENTED = new()
+ {
+ WriteIndented = true,
+ };
+
+ private static readonly JsonSerializerOptions INDENTED_WITH_VECTORS = new()
+ {
+ WriteIndented = true,
+ Converters = { new Vector2Json() },
+ };
+
+ private static readonly JsonSerializerOptions LOCALIZATION = new()
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ internal static string RootPath => Path.GetFullPath(Plugin.DUMPED_DATA_PATH);
+
+ internal static void DumpAll()
+ {
+ Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH);
+ DumpLogic();
+ DumpLocalization();
+ DumpSprites();
+ DumpTribePreviews();
+ DumpPrefabs();
+ }
+
+ ///
+ /// Dumps every version of the game logic and avatar data.
+ ///
+ internal static void DumpLogic()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.LOGIC_DUMP_PATH);
+ for (int version = 1; version <= VersionManager.GameLogicDataVersion; version++)
+ {
+ File.WriteAllText(
+ Path.Combine(Plugin.LOGIC_DUMP_PATH, $"GameLogicData{version}.json"),
+ PolytopiaDataManager.provider.LoadGameLogicData(version)
+ );
+ }
+ for (int version = 1; version <= VersionManager.AvatarDataVersion; version++)
+ {
+ File.WriteAllText(
+ Path.Combine(Plugin.LOGIC_DUMP_PATH, $"AvatarData{version}.json"),
+ PolytopiaDataManager.provider.LoadAvatarData(version)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping logic failed: {exception}");
+ }
+ }
+
+ internal static void DumpLocalization()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.LOCALIZATION_DUMP_PATH);
+ var source = I2.Loc.LocalizationManager.Sources[0];
+
+ foreach (var language in source.GetLanguages())
+ {
+ int languageIndex = source.GetLanguageIndex(language);
+ Dictionary translations = new();
+ foreach (var term in source.mTerms)
+ {
+ string translation = term.GetTranslation(languageIndex);
+ if (!string.IsNullOrEmpty(translation))
+ translations[term.Term] = translation;
+ }
+ File.WriteAllText(
+ Path.Combine(
+ Plugin.LOCALIZATION_DUMP_PATH,
+ $"language_{source.mLanguages[languageIndex].Code}.json"
+ ),
+ JsonSerializer.Serialize(translations, LOCALIZATION)
+ );
+ }
+
+ foreach (var category in source.GetCategories())
+ {
+ File.WriteAllText(
+ Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"localization_{category}.csv"),
+ source.Export_CSV(category)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping localization failed: {exception}");
+ }
+ }
+
+ internal static void DumpSprites()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.SPRITES_DUMP_PATH);
+ SpriteAtlasManager spriteAtlasManager = GameManager.GetSpriteAtlasManager();
+
+ for (int i = 0; i < AtlasData.raw.Count; i++)
+ {
+ if (spriteAtlasManager.loadedAtlases[i] == null) continue;
+
+ string[] row = AtlasData.raw[i];
+ string atlasName = row[0];
+ string directory = Path.Combine(Plugin.SPRITES_DUMP_PATH, atlasName);
+ Directory.CreateDirectory(directory);
+
+ Dictionary atlasSpriteInfo = new();
+ for (int j = 1; j < row.Length; j++)
+ {
+ string spriteName = row[j];
+ Sprite sprite = spriteAtlasManager.GetSprite(spriteName, atlasName);
+ if (sprite == null) continue;
+
+ (byte[] bytes, Visual.SpriteInfo spriteInfo) = Visual.SpriteToPng(sprite);
+ File.WriteAllBytes(Path.Combine(directory, $"{spriteName}.png"), bytes);
+ atlasSpriteInfo[spriteName] = spriteInfo;
+ }
+ File.WriteAllText(
+ Path.Combine(directory, $"sprites_{atlasName}.json"),
+ JsonSerializer.Serialize(atlasSpriteInfo, INDENTED_WITH_VECTORS)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping sprites failed: {exception}");
+ }
+ }
+
+ internal static void DumpTribePreviews()
+ {
+ try
+ {
+ foreach (TribeType type in Enum.GetValues())
+ {
+ SaveStateData saveStateData = TribePreviewRegistry.GetTribePreviewData(type, SkinType.Default); // TODO: skins
+ if (saveStateData == null) continue;
+
+ if (!DiskSerializationHelpers.FromLZ4CompressedByteArray(
+ saveStateData.byteArray, out ClientSerializationWrapper wrapper, out int _))
+ {
+ Plugin.logger.LogWarning($"Dump skipped preview of {type}: save state could not be read");
+ continue;
+ }
+
+ List previewTiles = new();
+ foreach (var tileData in wrapper.GetCurrentGameState().Map.Tiles)
+ {
+ previewTiles.Add(new TribePreviews.PreviewTile
+ {
+ x = tileData.coordinates.x,
+ y = tileData.coordinates.y,
+ terrainType = tileData.terrain,
+ resourceType = tileData.resource != null ? tileData.resource.type : null,
+ unitType = tileData.unit != null ? tileData.unit.type : null,
+ improvementType = tileData.improvement != null ? tileData.improvement.type : null,
+ tileEffects = tileData.effects.ToArray().ToList(),
+ });
+ }
+
+ File.WriteAllText(
+ Path.Combine(Plugin.DUMPED_DATA_PATH, $"preview_{type}.json"),
+ JsonSerializer.Serialize(previewTiles, INDENTED)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping tribe previews failed: {exception}");
+ }
+ }
+
+ internal static void DumpPrefabs()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.PREFABS_DUMP_PATH);
+ PrefabsInfo prefabsInfo = new();
+
+ foreach (var entry in Prefabs.unitsNew)
+ {
+ List? visualParts = CollectVisualParts(entry.Value, entry.Key.unitType);
+ if (visualParts == null) continue;
+
+ prefabsInfo.units.Add(new UnitPrefabInfo
+ {
+ headPositionMarker = entry.Value.headPositionMarker.gameObject.name,
+ skinnedKey = entry.Key,
+ visualParts = visualParts,
+ });
+ }
+
+ foreach (var entry in PrefabManager.improvements)
+ {
+ List? visualParts = CollectVisualParts(entry.Value, entry.Key);
+ if (visualParts == null) continue;
+
+ prefabsInfo.improvements.Add(new ImprovementPrefabInfo
+ {
+ improvementType = entry.Key,
+ visualParts = visualParts,
+ });
+ }
+
+ foreach (var entry in PrefabManager.resources)
+ {
+ List? visualParts = CollectVisualParts(entry.Value, entry.Key);
+ if (visualParts == null) continue;
+
+ prefabsInfo.resources.Add(new ResourcePrefabInfo
+ {
+ resourceType = entry.Key,
+ visualParts = visualParts,
+ });
+ }
+
+ // TODO
+ // foreach (var entry in PrefabManager.weapons)
+ // {
+ // WeaponGFX weaponGFX = entry.Value;
+ // prefabsInfo.weapons.Add(new WeaponPrefabInfo
+ // {
+ // weaponEnum = entry.Key,
+ // spriteName = weaponGFX.defaultSprite.name,
+ // pathType = weaponGFX.pathType,
+ // pathMode = weaponGFX.pathMode,
+ // pathResolution = weaponGFX.pathResolution,
+ // pathEase = weaponGFX.pathEase,
+ // lookAheadOffset = weaponGFX.lookAheadOffset,
+ // handleAngleOffset = weaponGFX.handleAngleOffset,
+ // sfxType = SFXTypes.None, //TODO
+ // weaponPuffInfo = null, //TODO
+ // weaponRangedInfo = null //TODO
+ // });
+ // }
+
+ File.WriteAllText(
+ Path.Combine(Plugin.PREFABS_DUMP_PATH, "prefabs.json"),
+ JsonSerializer.Serialize(prefabsInfo, INDENTED_WITH_VECTORS)
+ );
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping prefabs failed: {exception}");
+ }
+ }
+
+ private static List? CollectVisualParts(Component prefab, object key)
+ {
+ SkinVisualsReference skinVisualsReference = prefab.GetComponent();
+ if (skinVisualsReference == null || skinVisualsReference.visualParts == null)
+ {
+ Plugin.logger.LogWarning($"Dump skipped {key}: no SkinVisualsReference");
+ return null;
+ }
+
+ List visualParts = new();
+ foreach (var visualPart in skinVisualsReference.visualParts)
+ {
+ if (visualPart == null || visualPart.visualPart == null) continue;
+
+ VisualPartInfo visualPartInfo = new();
+ visualPartInfo.CopyFromVisualPart(visualPart);
+ visualParts.Add(visualPartInfo);
+ }
+ return visualParts;
+ }
+}
diff --git a/src/Json/EnumCacheJson.cs b/src/Json/EnumCacheJson.cs
index 5f561c6..b0545f8 100644
--- a/src/Json/EnumCacheJson.cs
+++ b/src/Json/EnumCacheJson.cs
@@ -11,70 +11,70 @@ namespace PolyMod.Json;
/// The enum type being converted.
public class EnumCacheJson : JsonConverter where T : struct, Enum
{
- ///
- /// Determines whether the converter can handle the given type.
- /// Supports both the enum type and .
- ///
- /// The type to check for compatibility.
- /// True if the type can be converted; otherwise false.
- public override bool CanConvert(Type typeToConvert)
- {
- return typeToConvert == typeof(T) || typeToConvert == typeof(List);
- }
-
- ///
- /// Reads a JSON string and converts it into the corresponding enum value
- /// using the EnumCache lookup.
- ///
- /// The JSON reader.
- /// The type to convert into.
- /// Serialization options.
- /// The enum value represented by the JSON string.
- /// Thrown if the type is not supported.
- public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- if (typeToConvert == typeof(T))
- {
- return EnumCache.GetType(reader.GetString());
- }
-
- throw new NotSupportedException("EnumCacheJson does not support reading this type directly.");
- }
-
-
- ///
- /// Writes the enum value as its cached string representation.
- ///
- /// The JSON writer.
- /// The enum value to write.
- /// Serialization options.
- public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
- {
- writer.WriteStringValue(EnumCache.GetName(value));
- }
-
- ///
- /// Reads a JSON property name and converts it into the corresponding enum value.
- ///
- /// The JSON reader.
- /// The target type.
- /// Serialization options.
- /// The enum value represented by the property name.
- public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- return Read(ref reader, typeToConvert, options);
- }
-
- ///
- /// Writes the enum value as a property name using its cached string representation.
- ///
- /// The JSON writer.
- /// The enum value to write.
- /// Serialization options.
- public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
- {
- writer.WritePropertyName(EnumCache.GetName(value));
- }
+ ///
+ /// Determines whether the converter can handle the given type.
+ /// Supports both the enum type and .
+ ///
+ /// The type to check for compatibility.
+ /// True if the type can be converted; otherwise false.
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return typeToConvert == typeof(T) || typeToConvert == typeof(List);
+ }
+
+ ///
+ /// Reads a JSON string and converts it into the corresponding enum value
+ /// using the EnumCache lookup.
+ ///
+ /// The JSON reader.
+ /// The type to convert into.
+ /// Serialization options.
+ /// The enum value represented by the JSON string.
+ /// Thrown if the type is not supported.
+ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (typeToConvert == typeof(T))
+ {
+ return EnumCache.GetType(reader.GetString());
+ }
+
+ throw new NotSupportedException("EnumCacheJson does not support reading this type directly.");
+ }
+
+
+ ///
+ /// Writes the enum value as its cached string representation.
+ ///
+ /// The JSON writer.
+ /// The enum value to write.
+ /// Serialization options.
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(EnumCache.GetName(value));
+ }
+
+ ///
+ /// Reads a JSON property name and converts it into the corresponding enum value.
+ ///
+ /// The JSON reader.
+ /// The target type.
+ /// Serialization options.
+ /// The enum value represented by the property name.
+ public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return Read(ref reader, typeToConvert, options);
+ }
+
+ ///
+ /// Writes the enum value as a property name using its cached string representation.
+ ///
+ /// The JSON writer.
+ /// The enum value to write.
+ /// Serialization options.
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ writer.WritePropertyName(EnumCache.GetName(value));
+ }
}
///
@@ -84,52 +84,52 @@ public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSer
/// The enum type being converted.
public class EnumCacheListJson : JsonConverter> where T : struct, Enum
{
- ///
- /// Inner converter used to handle individual enum values.
- ///
- private readonly EnumCacheJson _inner = new();
-
- ///
- /// Reads a JSON array of enum string values and converts them into a list of enum values.
- ///
- /// The JSON reader.
- /// The type to convert into.
- /// Serialization options.
- /// A list of enum values.
- /// Thrown if the JSON is not an array.
- public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- var list = new List();
-
- if (reader.TokenType != JsonTokenType.StartArray)
- throw new JsonException("Expected StartArray token for enum list");
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndArray)
- break;
-
- list.Add(_inner.Read(ref reader, typeof(T), options));
- }
-
- return list;
- }
-
- ///
- /// Writes a list of enum values as a JSON array of strings.
- ///
- /// The JSON writer.
- /// The list of enum values to write.
- /// Serialization options.
- public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options)
- {
- writer.WriteStartArray();
-
- foreach (var item in value)
- {
- _inner.Write(writer, item, options);
- }
-
- writer.WriteEndArray();
- }
+ ///
+ /// Inner converter used to handle individual enum values.
+ ///
+ private readonly EnumCacheJson _inner = new();
+
+ ///
+ /// Reads a JSON array of enum string values and converts them into a list of enum values.
+ ///
+ /// The JSON reader.
+ /// The type to convert into.
+ /// Serialization options.
+ /// A list of enum values.
+ /// Thrown if the JSON is not an array.
+ public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var list = new List();
+
+ if (reader.TokenType != JsonTokenType.StartArray)
+ throw new JsonException("Expected StartArray token for enum list");
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ break;
+
+ list.Add(_inner.Read(ref reader, typeof(T), options));
+ }
+
+ return list;
+ }
+
+ ///
+ /// Writes a list of enum values as a JSON array of strings.
+ ///
+ /// The JSON writer.
+ /// The list of enum values to write.
+ /// Serialization options.
+ public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+
+ foreach (var item in value)
+ {
+ _inner.Write(writer, item, options);
+ }
+
+ writer.WriteEndArray();
+ }
}
diff --git a/src/Json/EnumNameJson.cs b/src/Json/EnumNameJson.cs
new file mode 100644
index 0000000..cbf157b
--- /dev/null
+++ b/src/Json/EnumNameJson.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PolyMod.Json;
+
+///
+/// Converts fixed (non-extensible) enums to and from their plain string names.
+/// Use this for enums that are not registered in EnumCache.
+///
+public class EnumNameJson : JsonConverter where TEnum : struct, Enum
+{
+ public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? enumName = reader.GetString();
+
+ if (enumName != null && Enum.TryParse(enumName, true, out TEnum parsedValue))
+ {
+ return parsedValue;
+ }
+
+ string validNames = string.Join(", ", Enum.GetNames());
+ throw new JsonException($"Unknown {typeof(TEnum).Name} value '{enumName}'. Valid values: {validNames}");
+ }
+
+ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/src/Json/JsonMerger.cs b/src/Json/JsonMerger.cs
index 44eb212..a556202 100644
--- a/src/Json/JsonMerger.cs
+++ b/src/Json/JsonMerger.cs
@@ -8,107 +8,107 @@ namespace PolyMod.Json;
///
public static class JsonMerger
{
- ///
- /// Merges a patch JObject into an original JObject.
- ///
- /// The original JObject.
- /// The patch JObject to merge.
- /// The merged JObject.
- public static JObject Merge(JObject original, JObject patch)
- {
- patch = MergeRecursive(original, patch);
- original.Merge(patch, new() { MergeArrayHandling = MergeArrayHandling.Replace, MergeNullValueHandling = MergeNullValueHandling.Merge });
- return original;
- }
+ ///
+ /// Merges a patch JObject into an original JObject.
+ ///
+ /// The original JObject.
+ /// The patch JObject to merge.
+ /// The merged JObject.
+ public static JObject Merge(JObject original, JObject patch)
+ {
+ patch = MergeRecursive(original, patch);
+ original.Merge(patch, new() { MergeArrayHandling = MergeArrayHandling.Replace, MergeNullValueHandling = MergeNullValueHandling.Merge });
+ return original;
+ }
- ///
- /// Recursively applies custom merging logic to arrays.
- ///
- ///
- ///
- ///
- private static JObject MergeRecursive(JObject original, JObject patch)
- {
- foreach (var property in patch.Properties().ToArray().ToList())
- {
- if (property == null || property.Name == null)
- continue;
+ ///
+ /// Recursively applies custom merging logic to arrays.
+ ///
+ ///
+ ///
+ ///
+ private static JObject MergeRecursive(JObject original, JObject patch)
+ {
+ foreach (var property in patch.Properties().ToArray().ToList())
+ {
+ if (property == null || property.Name == null)
+ continue;
- string propName = property.Name;
- JToken? originalValue = original[propName];
- JToken patchValue = property.Value;
+ string propName = property.Name;
+ JToken? originalValue = original[propName];
+ JToken patchValue = property.Value;
- if (patchValue.Type == JTokenType.Array)
- {
- if (originalValue == null)
- originalValue = new JArray();
+ if (patchValue.Type == JTokenType.Array)
+ {
+ if (originalValue == null)
+ originalValue = new JArray();
- JArray originalArr = originalValue.Cast();
- JArray patchArr = patchValue.Cast();
- bool isSkins = propName.Equals("skins", StringComparison.OrdinalIgnoreCase);
- JArray merged = MergeArrays(originalArr, patchArr, isSkins);
- patch[propName] = merged;
- }
- else if (patchValue.Type == JTokenType.Object)
- {
- if (originalValue == null || originalValue.Type != JTokenType.Object)
- originalValue = new JObject();
+ JArray originalArr = originalValue.Cast();
+ JArray patchArr = patchValue.Cast();
+ bool isSkins = propName.Equals("skins", StringComparison.OrdinalIgnoreCase);
+ JArray merged = MergeArrays(originalArr, patchArr, isSkins);
+ patch[propName] = merged;
+ }
+ else if (patchValue.Type == JTokenType.Object)
+ {
+ if (originalValue == null || originalValue.Type != JTokenType.Object)
+ originalValue = new JObject();
- MergeRecursive(originalValue.Cast(), patchValue.Cast());
- }
- }
- return patch;
- }
+ MergeRecursive(originalValue.Cast(), patchValue.Cast());
+ }
+ }
+ return patch;
+ }
- ///
- /// Merges two JArrays, with special handling for adding and removing elements.
- ///
- /// The original JArray.
- /// The patch JArray to merge.
- /// A flag to indicate if the array is a 'skins' array, which has special merging logic.
- /// The merged JArray.
- private static JArray MergeArrays(JArray original, JArray patch, bool isSkins)
- {
- var result = new JArray(original);
- var patchList = patch._values.ToArray().ToList();
+ ///
+ /// Merges two JArrays, with special handling for adding and removing elements.
+ ///
+ /// The original JArray.
+ /// The patch JArray to merge.
+ /// A flag to indicate if the array is a 'skins' array, which has special merging logic.
+ /// The merged JArray.
+ private static JArray MergeArrays(JArray original, JArray patch, bool isSkins)
+ {
+ var result = new JArray(original);
+ var patchList = patch._values.ToArray().ToList();
- bool hasCustomValues = false;
- foreach (var token in patchList)
- {
- if (token.Type != JTokenType.String)
- {
- result.Add(token);
- continue;
- }
+ bool hasCustomValues = false;
+ foreach (var token in patchList)
+ {
+ if (token.Type != JTokenType.String)
+ {
+ result.Add(token);
+ continue;
+ }
- string str = token.ToString();
+ string str = token.ToString();
- if (str.StartsWith("+"))
- {
- string value = str.Substring(1);
- result.Add(value);
- hasCustomValues = true;
- }
- else if (str.StartsWith("-"))
- {
- string value = str.Substring(1);
- var toRemove = result._values.ToArray()
- .Where(t => t.Type == JTokenType.String && t.ToString() == value)
- .ToList();
- foreach (var rem in toRemove)
- result.Remove(rem);
- hasCustomValues = true;
- }
- else if (isSkins)
- {
- result.Add(str);
- hasCustomValues = true;
- }
- }
- if (!hasCustomValues)
- {
- result = new JArray(patch);
- }
- return result;
- }
+ if (str.StartsWith('+'))
+ {
+ string value = str.Substring(1);
+ result.Add(value);
+ hasCustomValues = true;
+ }
+ else if (str.StartsWith('-'))
+ {
+ string value = str.Substring(1);
+ var toRemove = result._values.ToArray()
+ .Where(t => t.Type == JTokenType.String && t.ToString() == value)
+ .ToList();
+ foreach (var rem in toRemove)
+ result.Remove(rem);
+ hasCustomValues = true;
+ }
+ else if (isSkins)
+ {
+ result.Add(str);
+ hasCustomValues = true;
+ }
+ }
+ if (!hasCustomValues)
+ {
+ result = new JArray(patch);
+ }
+ return result;
+ }
}
diff --git a/src/Json/Vector2Json.cs b/src/Json/Vector2Json.cs
index 1957c4d..a36e5df 100644
--- a/src/Json/Vector2Json.cs
+++ b/src/Json/Vector2Json.cs
@@ -9,40 +9,40 @@ namespace PolyMod.Json;
///
public class Vector2Json : JsonConverter
{
- ///
- /// Reads and converts the JSON to a .
- ///
- /// The reader.
- /// The type to convert.
- /// An object that specifies serialization options to use.
- /// The converted value.
- public override Vector2 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- List values = new();
- if (reader.TokenType == JsonTokenType.StartArray)
- {
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndArray) break;
- if (reader.TokenType != JsonTokenType.Number) throw new JsonException();
- values.Add(reader.GetSingle());
- }
- }
- if (values.Count != 2) throw new JsonException();
- return new(values[0], values[1]);
- }
+ ///
+ /// Reads and converts the JSON to a .
+ ///
+ /// The reader.
+ /// The type to convert.
+ /// An object that specifies serialization options to use.
+ /// The converted value.
+ public override Vector2 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ List values = new();
+ if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray) break;
+ if (reader.TokenType != JsonTokenType.Number) throw new JsonException();
+ values.Add(reader.GetSingle());
+ }
+ }
+ if (values.Count != 2) throw new JsonException();
+ return new(values[0], values[1]);
+ }
- ///
- /// Writes a specified value as JSON.
- ///
- /// The writer to write to.
- /// The value to convert to JSON.
- /// An object that specifies serialization options to use.
- public override void Write(Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options)
- {
- writer.WriteStartArray();
- writer.WriteNumberValue(value.x);
- writer.WriteNumberValue(value.y);
- writer.WriteEndArray();
- }
+ ///
+ /// Writes a specified value as JSON.
+ ///
+ /// The writer to write to.
+ /// The value to convert to JSON.
+ /// An object that specifies serialization options to use.
+ public override void Write(Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+ writer.WriteNumberValue(value.x);
+ writer.WriteNumberValue(value.y);
+ writer.WriteEndArray();
+ }
}
diff --git a/src/Json/VersionJson.cs b/src/Json/VersionJson.cs
index 21c6c81..47b9067 100644
--- a/src/Json/VersionJson.cs
+++ b/src/Json/VersionJson.cs
@@ -8,26 +8,26 @@ namespace PolyMod.Json;
///
public class VersionJson : JsonConverter
{
- ///
- /// Reads and converts the JSON to a .
- ///
- /// The reader.
- /// The type to convert.
- /// An object that specifies serialization options to use.
- /// The converted value.
- public override Version? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- return new(reader.GetString()!);
- }
+ ///
+ /// Reads and converts the JSON to a .
+ ///
+ /// The reader.
+ /// The type to convert.
+ /// An object that specifies serialization options to use.
+ /// The converted value.
+ public override Version? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ return new(reader.GetString()!);
+ }
- ///
- /// Writes a specified value as JSON.
- ///
- /// The writer to write to.
- /// The value to convert to JSON.
- /// An object that specifies serialization options to use.
- public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
- {
- writer.WriteStringValue(value.ToString());
- }
+ ///
+ /// Writes a specified value as JSON.
+ ///
+ /// The writer to write to.
+ /// The value to convert to JSON.
+ /// An object that specifies serialization options to use.
+ public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
}
diff --git a/src/Loader.cs b/src/Loader.cs
index 87d5a76..1c99191 100644
--- a/src/Loader.cs
+++ b/src/Loader.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using BepInEx.Logging;
using Cpp2IL.Core.Extensions;
using Il2CppSystem.Linq;
@@ -8,10 +9,8 @@
using Polytopia.Data;
using PolytopiaBackendBase.Game;
using System.Diagnostics;
-using System.Globalization;
using System.IO.Compression;
using System.Reflection;
-using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using UnityEngine;
@@ -24,1096 +23,992 @@ namespace PolyMod;
///
public static class Loader
{
- internal record TypeMapping(Type type, bool shouldCreateCache = true);
-
- ///
- /// Mappings from JSON data types to their corresponding C# types.
- ///
- internal static Dictionary typeMappings = new()
- {
- { "tribeData", new TypeMapping(typeof(TribeType)) },
- { "techData", new TypeMapping(typeof(TechData.Type)) },
- { "unitData", new TypeMapping(typeof(UnitData.Type)) },
- { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) },
- { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) },
- { "resourceData", new TypeMapping(typeof(ResourceData.Type)) },
- { "taskData", new TypeMapping(typeof(TaskData.Type)) },
- { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) },
- { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) },
- { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) },
- { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) },
- { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) },
- { "skinData", new TypeMapping(typeof(SkinData), false) }
- };
-
- ///
- /// List of custom game modes to be added.
- ///
- internal static List gamemodes = new();
-
- ///
- /// Handlers for processing specific data types during mod loading.
- ///
- internal static readonly Dictionary>> typeHandlers = new()
- {
- [typeof(TribeType)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if (duringEnumCacheCreation)
- {
- Registry.customTribes.Add((TribeType)(int)token["idx"]);
- token["style"] = Registry.climateAutoidx;
- token["climate"] = Registry.climateAutoidx;
- Registry.climateAutoidx++;
- }
- else
- {
- if (token["skins"] != null)
- {
- JArray skins = token["skins"].Cast();
- List skinValues = skins._values.ToArray().ToList();
- foreach (var skin in skinValues)
- {
- string skinId = skin.ToString();
- if (!Enum.TryParse(skinId, ignoreCase: true, out _))
- {
- SkinType skinValue = (SkinType)Registry.autoidx;
- EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
- EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
- Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null));
- Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx);
- Registry.autoidx++;
- if(token["tribeAbilities"] != null)
- {
- JArray tribeAbilities = token["tribeAbilities"].Cast();
- List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList();
-
- foreach(JToken ability in tribeAbilitiesValues)
- {
- string abilityValue = ability.ToString();
-
- if (Enum.TryParse(
- abilityValue, ignoreCase: true,
- out TribeAbility.Type tribeAbilityType
- ) && tribeAbilityType == TribeAbility.Type.Flooded)
- {
- string tileEffectId = skinId.ToLowerInvariant() + "_flood";
- EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
- EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
- Visual.customFloodingSkins.Add((TileData.EffectType)Registry.autoidx, skinValue);
- Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx);
- Registry.autoidx++;
- }
- }
- }
- }
- }
- Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values;
- foreach (var skin in Registry.skinInfo)
- {
- if (modifiedSkins.Contains(skin.id))
- {
- modifiedSkins.Remove(skin.id);
- modifiedSkins.Add(skin.idx.ToString());
- }
- }
- JArray newSkins = new JArray();
- foreach (var item in modifiedSkins)
- {
- newSkins.Add(item);
- }
- token["skins"] = newSkins;
- }
- if (token["preview"] != null)
- {
- Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!;
- Registry.tribePreviews[Util.GetJTokenName(token)] = preview;
- }
- }
- })
- },
- [typeof(UnitData.Type)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if(duringEnumCacheCreation)
- {
- if (token["prefab"] == null)
- {
- Registry.prefabNames.Add((int)token["idx"], EnumCache.GetName(UnitData.Type.Scout).ToLowerInvariant());
- }
- }
- else
- {
- if (token["prefab"] != null)
- {
- Registry.prefabNames.Add((int)token["idx"], token["prefab"].ToString().ToLowerInvariant());
- }
- if (token["embarksTo"] != null)
- {
- string unitId = Util.GetJTokenName(token);
- string embarkUnitId = token["embarksTo"].ToString();
- Main.embarkNames[unitId] = embarkUnitId;
- }
- if (token["weapon"] != null)
- {
- string weaponString = token["weapon"].ToString();
- if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type))
- {
- token["weapon"] = (int)type;
- }
- }
- }
- })
- },
-
- [typeof(ImprovementData.Type)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if (duringEnumCacheCreation)
- {
- ImprovementData.Type improvementPrefabType = ImprovementData.Type.CustomsHouse;
- if (token["prefab"] != null)
- {
- string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString());
- if (Enum.TryParse(prefabId, out ImprovementData.Type parsedType))
- improvementPrefabType = parsedType;
- }
- if(token["idx"] != null)
- PrefabManager.improvements.TryAdd((ImprovementData.Type)(int)token["idx"], PrefabManager.improvements[improvementPrefabType]);
- }
- else
- {
- string improvementId = Util.GetJTokenName(token);
- if (token["attractsResource"] != null)
- {
- string attractsId = token["attractsResource"].ToString();
- Main.attractsResourceNames[improvementId] = attractsId;
- }
- if (token["attractsToTerrain"] != null)
- {
- string attractsId = token["attractsToTerrain"].ToString();
- Main.attractsTerrainNames[improvementId] = attractsId;
- }
- if(token["infoOverride"] != null)
- {
- Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString());
- }
- }
- })
- },
-
- [typeof(ResourceData.Type)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if (duringEnumCacheCreation)
- {
- ResourceData.Type resourcePrefabType = ResourceData.Type.Game;
- if (token["prefab"] != null)
- {
- string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString());
- if (Enum.TryParse(prefabId, out ResourceData.Type parsedType))
- resourcePrefabType = parsedType;
- }
- if(token["idx"] != null)
- PrefabManager.resources.TryAdd((ResourceData.Type)(int)token["idx"], PrefabManager.resources[resourcePrefabType]);
- }
- })
- },
-
- [typeof(SkinData)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- var prop = token.Parent.Cast();
- prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value));
- })
- },
- };
-
- ///
- /// Represents information for a custom game mode button.
- ///
- /// The index of the game mode.
- /// The action to perform when the button is clicked.
- /// The index of the button in the UI.
- /// The sprite for the button.
- /// The name of the sprite for the button.
- public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName);
-
- ///
- /// Adds a new game mode button.
- ///
- /// The unique identifier for the game mode.
- /// The action to perform when the button is clicked.
- /// The boolean which decides if GameMode should appear in the menu.
- /// The sprite for the button.
- /// The name of the sprite for the button.
- public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null)
- {
- EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
- EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
- if (shouldShowInMenu)
- gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName));
- Registry.gameModesAutoidx++;
- }
-
- ///
- /// Adds a new data type for patching.
- ///
- /// The identifier for the data type in JSON.
- /// "The C# type corresponding to the identifier.
- public static void AddPatchDataType(string typeId, Type type)
- {
- if (!typeMappings.ContainsKey(typeId))
- typeMappings.Add(typeId, new TypeMapping(type));
- }
-
- public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache)
- {
- if (!typeMappings.ContainsKey(typeId))
- typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache));
- }
-
- public static void AddTypeHandler(Type type, Action handler)
- {
- string typeString = type.ToString();
- if (!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false)))
- {
- Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first.");
- return;
- }
- if (!typeHandlers.ContainsKey(type))
- typeHandlers[type] = new();
-
- typeHandlers[type].Add(handler);
-
- Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}.");
- }
-
- ///
- /// Loads all mods from the mods directory.
- ///
- /// A dictionary to populate with the loaded mods.
- internal static void RegisterMods(Dictionary mods)
- {
- Directory.CreateDirectory(Plugin.MODS_PATH);
- string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH)
- .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod"))
- .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip"))
- .ToArray();
- foreach (var modContainer in modContainers)
- {
- Mod.Manifest? manifest = null;
- List files = new();
-
- // Load mod from directory or zip archive
- if (Directory.Exists(modContainer))
- {
- foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories))
- {
- ProcessModFile(
- Path.GetRelativePath(modContainer, file),
- File.ReadAllBytes(file),
- files,
- ref manifest
- );
- }
- }
- else
- {
- foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries)
- {
- ProcessModFile(
- entry.FullName,
- entry.ReadBytes(),
- files,
- ref manifest
- );
- }
- }
- #region ValidateManifest()
- if (manifest == null)
- {
- Plugin.logger.LogError($"Mod manifest not found in {modContainer}");
- continue;
- }
- if (manifest.id == null)
- {
- Plugin.logger.LogError($"Mod id not found in {modContainer}");
- continue;
- }
- if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$"))
- {
- Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}");
- continue;
- }
- if (manifest.version == null)
- {
- Plugin.logger.LogError($"Mod version not found in {modContainer}");
- continue;
- }
- if (manifest.authors == null || manifest.authors.Length == 0)
- {
- Plugin.logger.LogError($"Mod authors not found in {modContainer}");
- continue;
- }
- if (mods.ContainsKey(manifest.id))
- {
- Plugin.logger.LogError($"Mod {manifest.id} already exists");
- continue;
- }
- #endregion
- mods.Add(manifest.id, new(
- manifest,
- Mod.Status.Success,
- files
- ));
- Plugin.logger.LogInfo($"Registered mod {manifest.id}");
- }
-
- CheckDependencies(mods);
- }
-
- private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest)
- {
- if (fullName == "manifest.json")
- {
- manifest = JsonSerializer.Deserialize(
- bytes,
- new JsonSerializerOptions()
- {
- Converters = { new VersionJson() },
- }
- );
- return;
- }
- files.Add(new(fullName, bytes));
- }
-
- internal static void LoadMods(Dictionary mods, out bool dependencyCycle)
- {
- dependencyCycle = !SortMods(Registry.mods);
- if (dependencyCycle) return;
-
- StringBuilder checksumString = new();
- foreach (var (id, mod) in Registry.mods)
- {
- if (mod.status != Mod.Status.Success) continue;
- foreach (var file in mod.files)
- {
- checksumString.Append(JsonSerializer.Serialize(file));
- if (Path.GetExtension(file.name) == ".dll")
- {
- LoadAssemblyFile(mod, file);
- }
- Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$");
- if (spritesMatch.Success)
- {
- LoadSpriteInfoFile(mod, file);
- }
- Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$");
- if (languageMatch.Success)
- {
- string languageName = languageMatch.Groups[1].Value;
- LoadLanguageFile(
- mod,
- file,
- languageName
- );
- }
- }
- if (!mod.client && id != "polytopia")
- {
- checksumString.Append(id);
- checksumString.Append(mod.version.ToString());
- }
- }
- Compatibility.HashSignatures(checksumString);
-
- }
- internal static void CheckDependencies(Dictionary mods)
- {
- foreach (var (id, mod) in mods)
- {
- foreach (var dependency in mod.dependencies ?? Array.Empty())
- {
- string? message = null;
- if (!mods.ContainsKey(dependency.id))
- {
- message = $"Dependency {dependency.id} not found";
- }
- else
- {
- Version version = mods[dependency.id].version;
- if (
- (dependency.min != null && version < dependency.min)
- ||
- (dependency.max != null && version > dependency.max)
- )
- {
- message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}";
- }
- }
- if (message != null)
- {
- if (dependency.required)
- {
- Plugin.logger.LogError(message);
- mod.status = Mod.Status.DependenciesUnsatisfied;
- }
- else
- {
- Plugin.logger.LogWarning(message);
- }
- }
- }
- }
- }
-
- ///
- /// Sorts mods based on their dependencies using a topological sort.
- ///
- /// The dictionary of mods to sort.
- /// True if the mods could be sorted (no circular dependencies), false otherwise.
- internal static bool SortMods(Dictionary mods)
- {
- Stopwatch s = new();
- Dictionary> graph = new();
- Dictionary inDegree = new();
- Dictionary successfulMods = new();
- Dictionary unsuccessfulMods = new();
- foreach (var (id, mod) in mods)
- {
- if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod);
- else unsuccessfulMods.Add(id, mod);
- }
- foreach (var (id, _) in successfulMods)
- {
- graph[id] = new();
- inDegree[id] = 0;
- }
- foreach (var (id, mod) in successfulMods)
- {
- foreach (var dependency in mod.dependencies ?? Array.Empty())
- {
- if (!graph.ContainsKey(dependency.id)) continue;
- graph[dependency.id].Add(id);
- inDegree[id]++;
- }
- }
- Queue queue = new();
- foreach (var (id, _) in successfulMods)
- {
- if (inDegree[id] == 0)
- {
- queue.Enqueue(id);
- }
- }
- Dictionary sorted = new();
- while (queue.Count > 0)
- {
- var id = queue.Dequeue();
- var mod = successfulMods[id];
- sorted.Add(id, mod);
- foreach (var neighbor in graph[id])
- {
- inDegree[neighbor]--;
- if (inDegree[neighbor] == 0)
- {
- queue.Enqueue(neighbor);
- }
- }
- }
- if (sorted.Count != successfulMods.Count)
- {
- return false;
- }
- mods.Clear();
- mods.AddRange(sorted);
- mods.AddRange(unsuccessfulMods);
-
- return true;
- }
-
- ///
- /// Loads an assembly file from a mod.
- ///
- /// The mod the assembly belongs to.
- /// The assembly file to load.
- public static void LoadAssemblyFile(Mod mod, Mod.File file)
- {
- try
- {
- Assembly assembly = Assembly.Load(file.bytes);
- if (assembly
- .GetTypes()
- .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase)))
- is { } modType)
- {
- var modInstance = (Api.PolyScriptBase)Activator.CreateInstance(modType)!;
- modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}"));
- modInstance.Load();
- return;
- }
- foreach (Type type in assembly.GetTypes())
- {
- MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) });
- if (loadWithLogger != null)
- {
- loadWithLogger.Invoke(null, new object[]
- {
- BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")
- });
- Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod");
- }
- MethodInfo? load = type.GetMethod("Load", Array.Empty());
- if (load != null)
- {
- load.Invoke(null, null);
- Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod");
- }
- }
- }
- catch (TargetInvocationException exception)
- {
- if (exception.InnerException != null)
- {
- Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}");
- mod.status = Mod.Status.Error;
- }
- }
- }
-
- ///
- /// Loads a localization file from a mod.
- ///
- /// The mod the localization file belongs to.
- /// The localization file to load.
- public static void LoadLocalizationFile(Mod mod, Mod.File file)
- {
- try
- {
- Loc.BuildAndLoadLocalization(JsonSerializer
- .Deserialize>>(file.bytes)!);
- Plugin.logger.LogInfo($"Registered localization from {mod.id} mod");
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}");
- }
- }
-
- public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName)
- {
- try
- {
- var dict = JsonSerializer.Deserialize>(file.bytes);
- if (dict != null)
- {
- Loc.languagesToAdd.Add(languageName, dict);
- Plugin.logger.LogInfo($"Registered language from {mod.id} mod");
- }
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}");
- }
- }
-
- ///
- /// Loads a sprite file from a mod.
- ///
- /// The mod the sprite file belongs to.
- /// The sprite file to load.
- public static void LoadSpriteFile(Mod mod, Mod.File file)
- {
- string name = Path.GetFileNameWithoutExtension(file.name);
- Vector2 pivot = name.Split("_")[0] switch
- {
- "field" => new(0.5f, 0.0f),
- "mountain" => new(0.5f, -0.375f),
- _ => new(0.5f, 0.5f),
- };
- float pixelsPerUnit = 2112f;
- if (Registry.spriteInfos.ContainsKey(name))
- {
- Visual.SpriteInfo spriteData = Registry.spriteInfos[name];
- pivot = spriteData.pivot ?? pivot;
- pixelsPerUnit = spriteData.pixelsPerUnit ?? pixelsPerUnit;
- }
- Sprite sprite = Visual.BuildSprite(file.bytes, pivot, pixelsPerUnit);
- GameManager.GetSpriteAtlasManager().cachedSprites.TryAdd("Heads", new());
- GameManager.GetSpriteAtlasManager().cachedSprites["Heads"].Add(name, sprite);
- Registry.sprites.Add(name, sprite);
- }
-
- ///
- /// Updates a sprite with new information.
- ///
- /// The name of the sprite to update.
- public static void UpdateSprite(string name)
- {
- if (Registry.spriteInfos.ContainsKey(name) && Registry.sprites.ContainsKey(name))
- {
- Visual.SpriteInfo spriteData = Registry.spriteInfos[name];
- Sprite sprite = Visual.BuildSpriteWithTexture(
- Registry.sprites[name].texture,
- spriteData.pivot,
- spriteData.pixelsPerUnit
- );
- GameManager.GetSpriteAtlasManager().cachedSprites["Heads"][name] = sprite;
- Registry.sprites[name] = sprite;
- }
- }
-
- ///
- /// Loads a sprite info file from a mod.
- ///
- /// The mod the sprite info file belongs to.
- /// The sprite info file to load.
- /// A dictionary of sprite information, or null if an error occurred.
- public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file)
- {
- try
- {
- var deserialized = JsonSerializer.Deserialize>(
- file.bytes,
- new JsonSerializerOptions()
- {
- Converters = { new Vector2Json() },
- }
- );
-
- if (deserialized != null)
- {
- foreach (var kvp in deserialized)
- {
- Registry.spriteInfos[kvp.Key] = kvp.Value;
- }
- }
-
- Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod");
- return deserialized;
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.StackTrace}");
- return null;
- }
- }
-
- ///
- /// Loads an audio file from a mod.
- ///
- /// The mod the audio file belongs to.
- /// The audio file to load.
- public static void LoadAudioFile(Mod mod, Mod.File file)
- {
- // AudioSource audioSource = new GameObject().AddComponent();
- // GameObject.DontDestroyOnLoad(audioSource);
- // audioSource.clip = Managers.Audio.BuildAudioClip(file.bytes);
- // Registry.audioClips.Add(Path.GetFileNameWithoutExtension(file.name), audioSource);
- // TODO: issue #71
- }
-
- ///
- /// Loads a prefab info file from a mod and creates a new unit prefab.
- ///
- /// The mod the prefab info file belongs to.
- /// The prefab info file to load.
- public static void LoadPrefabInfoFile(Mod mod, Mod.File file)
- {
- try
- {
- var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions
- {
- Converters = { new Vector2Json() },
- PropertyNameCaseInsensitive = true,
- });
- if (prefab == null || prefab.type != Visual.PrefabType.Unit || prefab.visualParts.Count == 0)
- return;
-
- if (Registry.unitPrefabs.Keys.Any(pref => pref.name == prefab.name))
- {
- Plugin.logger.LogInfo($"Prefab {prefab.name} already exists, skipping.");
- }
- var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.Imperius, SkinType.Default);
- if (baseUnit == null)
- return;
-
- var unitInstance = GameObject.Instantiate(baseUnit);
- if (unitInstance == null)
- return;
-
- var spriteContainer = unitInstance.transform.GetChild(0);
- var material = ClearExistingPartsAndExtractMaterial(spriteContainer);
-
- var visualParts = ApplyVisualParts(prefab, spriteContainer, material);
-
- Transform? headPositionMarker = null;
- foreach (var vp in visualParts)
- {
- if (vp.visualPart.gameObject.name == prefab.headPositionMarker)
- {
- headPositionMarker = vp.visualPart.gameObject.transform;
- break;
- }
- }
-
- unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform;
-
- var svr = unitInstance.GetComponent();
- svr.visualParts = visualParts.ToArray();
-
- GameObject.DontDestroyOnLoad(unitInstance.gameObject);
- Registry.unitPrefabs.Add(prefab, unitInstance.GetComponent());
-
- Plugin.logger.LogInfo($"Registered prefab {prefab.name} info from {mod.id} mod.");
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading prefab {Path.GetFileNameWithoutExtension(file.name)} info from {mod.id} mod: {e.StackTrace}.");
- }
- }
-
- ///
- /// Clears existing visual parts from a prefab and extracts the material.
- ///
- /// The transform containing the sprite parts.
- /// The material of the original sprite parts.
- private static Material? ClearExistingPartsAndExtractMaterial(Transform spriteContainer)
- {
- Material? material = null;
- for (int i = 0; i < spriteContainer.childCount; i++)
- {
- var child = spriteContainer.GetChild(i);
- if (child.gameObject.name == "Head")
- {
- var renderer = child.GetComponent();
- if (renderer != null)
- material = renderer.material;
- }
- GameObject.Destroy(child.gameObject);
- }
- return material;
- }
-
- ///
- /// Applies new visual parts to a prefab.
- ///
- /// A list of visual part information.
- /// The transform to add the parts to.
- /// The material to use for the parts.
- /// A list of the created visual parts.
- private static List ApplyVisualParts(
- Visual.PrefabInfo prefab,
- Transform spriteContainer,
- Material? material)
- {
- if (prefab.visualParts.Count == 0)
- return new List();
-
- List parts = new();
-
- foreach (var info in prefab.visualParts)
- {
- parts.Add(CreateVisualPart(info, spriteContainer, material));
- }
-
- return parts;
- }
-
- ///
- /// Creates a single visual part for a prefab.
- ///
- /// The information for the visual part.
- /// The parent transform for the part.
- /// The material to use for the part.
- /// The created visual part.
- private static SkinVisualsReference.VisualPart CreateVisualPart(
- Visual.VisualPartInfo info,
- Transform parent,
- Material? material)
- {
- var visualPartObj = new GameObject(info.gameObjectName);
- visualPartObj.transform.SetParent(parent);
- visualPartObj.transform.position = info.coordinates;
- visualPartObj.transform.localScale = info.scale;
- visualPartObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
-
- var outlineObj = new GameObject("Outline");
- outlineObj.transform.SetParent(visualPartObj.transform);
- outlineObj.transform.position = info.coordinates;
- outlineObj.transform.localScale = info.scale;
- outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
-
- var visualPart = new SkinVisualsReference.VisualPart
- {
- DefaultSpriteName = info.baseName,
- visualPart = visualPartObj,
- outline = outlineObj,
- tintable = info.tintable
- };
-
- var renderer = visualPartObj.AddComponent();
- renderer.material = material;
- renderer.sortingLayerName = "Units";
- renderer.sortingOrder = info.tintable ? 0 : 1;
-
- visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer };
-
- var outlineRenderer = outlineObj.AddComponent();
- outlineRenderer.material = material;
- outlineRenderer.sortingLayerName = "Units";
- outlineRenderer.sortingOrder = -1;
-
- visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer };
-
- return visualPart;
- }
-
- ///
- /// Loads and applies a game logic data patch from a mod.
- ///
- /// The mod the patch belongs to.
- /// The original game logic data.
- /// The patch to apply.
- public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch)
- {
- try
- {
- // Merge the patch into the game logic data
- gld = JsonMerger.Merge(gld, patch);
- Plugin.logger.LogInfo($"Registered patch from {mod.id} mod");
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}");
- mod.status = Mod.Status.Error;
- }
- }
-
- ///
- /// Loads an asset bundle from a mod.
- ///
- /// The mod the asset bundle belongs to.
- /// The asset bundle file to load.
- public static void LoadAssetBundle(Mod mod, Mod.File file)
- {
- Registry.assetBundles.Add(
- Path.GetFileNameWithoutExtension(file.name),
- AssetBundle.LoadFromMemory(file.bytes)
- );
- }
-
- ///
- /// Processes the merged game logic data after all mods have been loaded and patched.
- ///
- /// The game logic data object to populate.
- /// The root JObject of the merged game logic data.
- internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject)
- {
- try
- {
- CreateMappings(rootObject);
- ProcessCustomTribes();
- ProcessPrefabs();
- ProcessEmbarkOverrides();
- ProcessAttractOverrides();
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}");
- }
- }
-
- internal static void ProcessCustomTribes()
- {
- foreach (var tribe in Registry.customTribes)
- {
- if (!GameLogicData.legacyTribeTypesOrder.Contains(tribe))
- {
- GameLogicData.legacyTribeTypesOrder.Add(tribe);
- }
- }
- }
-
- ///
- /// Creates EnumCache mappings for custom enum values and invokes type handlers.
- ///
- ///
- internal static void CreateMappings(JObject rootObject)
- {
- foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
- {
- JObject? token = jtoken.TryCast();
- if (token == null)
- continue;
-
- string dataType = Util.GetJTokenName(token, 2);
- if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
- continue;
-
- if (token["idx"] == null || !typeMapping.shouldCreateCache)
- continue;
-
- Type targetType = typeMapping.type;
- if (!targetType.IsEnum)
- {
- Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!");
- continue;
- }
-
- string id = Util.GetJTokenName(token);
-
- if ((int)token["idx"] == -1)
- {
- token["idx"] = Registry.autoidx;
- Registry.autoidx++;
- }
- else if (Plugin.config.allowUnsafeIndexes)
- {
- Array values = Enum.GetValues(targetType);
-
- var maxValue = values.Cast().Max();
-
- if (maxValue >= (int)token["idx"])
- {
- continue;
- }
- }
- else
- {
- continue;
- }
-
- MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping");
- if (methodInfo == null)
- {
- Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}");
- continue;
- }
-
- methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
- methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
-
- if (typeHandlers.TryGetValue(targetType, out var handlers))
- {
- foreach (var handler in handlers)
- {
- handler(token, true);
- }
- }
- Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]);
- }
- foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
- {
- JObject? token = jtoken.TryCast();
- if (token != null)
- {
- string dataType = Util.GetJTokenName(token, 2);
- if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
- {
- if (typeHandlers.TryGetValue(typeMapping.type, out var handlers))
- {
- foreach (var handler in handlers)
- {
- handler(token, false);
- }
- }
- }
- }
- }
- }
-
- ///
- /// Processes the prefab registry and populates the PrefabManager with custom prefabs.
- ///
- internal static void ProcessPrefabs()
- {
- Plugin.logger.LogInfo($"Processing prefabs, count: {Registry.prefabNames.Count}.");
- foreach (KeyValuePair item in Registry.prefabNames)
- {
- UnitData.Type unitPrefabType = UnitData.Type.Scout;
- string prefabId = item.Value;
- int hashKey = PrefabManager.GetSkinnedHashKey((UnitData.Type)item.Key, TribeType.None, SkinType.Default);
- if (!Enum.TryParse(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(prefabId), out unitPrefabType)) // Existing unit prefabs
- {
- KeyValuePair prefabInfo = Registry.unitPrefabs.FirstOrDefault(
- kv => kv.Key.name == prefabId
- );
- if (!EqualityComparer.Default.Equals(prefabInfo.Key, default))
- {
- Visual.UnitPrefabInfo unitPrefabInfo = new(
- EnumCache.GetName((UnitData.Type)item.Key),
- EnumCache.GetName(TribeType.None),
- EnumCache.GetName(SkinType.Default)
- );
- Visual.customPrefabs.Add(unitPrefabInfo, prefabInfo.Value);
- Plugin.logger.LogInfo($"Using custom prefab {prefabId} for unit {item.Key}.");
- continue;
- }
- }
- Plugin.logger.LogInfo($"Using existing prefab {EnumCache.GetName(unitPrefabType)} for unit {item.Key}.");
- PrefabManager.units.TryAdd(
- hashKey,
- PrefabManager.units[PrefabManager.GetSkinnedHashKey(unitPrefabType, TribeType.None, SkinType.Default)]
- );
- }
- }
-
- ///
- /// Processes embark overrides by mapping original embark unit types to configured overrides.
- ///
- internal static void ProcessEmbarkOverrides()
- {
- foreach (KeyValuePair entry in Main.embarkNames)
- {
- try
- {
- UnitData.Type unit = EnumCache.GetType(entry.Key);
- UnitData.Type newUnit = EnumCache.GetType(entry.Value);
- Main.embarkOverrides[unit] = newUnit;
- Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}");
- }
- catch
- {
- Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}");
- }
- }
- }
-
- ///
- /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides.
- ///
- internal static void ProcessAttractOverrides()
- {
- foreach (KeyValuePair entry in Main.attractsResourceNames)
- {
- try
- {
- ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
- ResourceData.Type resource = EnumCache.GetType(entry.Value);
- Main.attractsResourceOverrides[improvement] = resource;
- Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}");
- }
- catch
- {
- Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}");
- }
- }
- foreach (KeyValuePair entry in Main.attractsTerrainNames)
- {
- try
- {
- ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
- Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value);
- Main.attractsTerrainOverrides[improvement] = terrain;
- Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}");
- }
- catch
- {
- Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}");
- }
- }
- }
+ private static readonly JsonSerializerOptions manifestJsonOptions = new()
+ {
+ Converters = { new VersionJson() },
+ };
+
+ private static readonly JsonSerializerOptions spriteInfoJsonOptions = new()
+ {
+ Converters = { new Vector2Json() },
+ };
+
+ private static readonly JsonSerializerOptions prefabJsonOptions = new()
+ {
+ Converters = { new Vector2Json() },
+ PropertyNameCaseInsensitive = true,
+ };
+
+ internal record TypeMapping(Type type, bool shouldCreateCache = true);
+
+ ///
+ /// Mappings from JSON data types to their corresponding C# types.
+ ///
+ internal static Dictionary typeMappings = new()
+ {
+ { "tribeData", new TypeMapping(typeof(TribeType)) },
+ { "techData", new TypeMapping(typeof(TechData.Type)) },
+ { "unitData", new TypeMapping(typeof(UnitData.Type)) },
+ { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) },
+ { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) },
+ { "resourceData", new TypeMapping(typeof(ResourceData.Type)) },
+ { "taskData", new TypeMapping(typeof(TaskData.Type)) },
+ { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) },
+ { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) },
+ { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) },
+ { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) },
+ { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) },
+ { "skinData", new TypeMapping(typeof(SkinData), false) }
+ };
+
+ ///
+ /// List of custom game modes to be added.
+ ///
+ internal static List gamemodes = new();
+
+ ///
+ /// Handlers for processing specific data types during mod loading.
+ ///
+ internal static readonly Dictionary>> typeHandlers = new()
+ {
+ [typeof(TribeType)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if (duringEnumCacheCreation)
+ {
+ Registry.customTribes.Add((TribeType)(int)token["idx"]);
+ token["style"] = Registry.climateAutoidx;
+ token["climate"] = Registry.climateAutoidx;
+ Registry.climateAutoidx++;
+ }
+ else
+ {
+ if (token["skins"] != null)
+ {
+ JArray skins = token["skins"].Cast();
+ List skinValues = skins._values.ToArray().ToList();
+ foreach (var skin in skinValues)
+ {
+ string skinId = skin.ToString();
+ if (!Enum.TryParse(skinId, ignoreCase: true, out _))
+ {
+ SkinType skinValue = (SkinType)Registry.autoidx;
+ EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
+ EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
+ Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null));
+ Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx);
+ Registry.autoidx++;
+ if(token["tribeAbilities"] != null)
+ {
+ JArray tribeAbilities = token["tribeAbilities"].Cast();
+ List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList();
+
+ foreach(JToken ability in tribeAbilitiesValues)
+ {
+ string abilityValue = ability.ToString();
+
+ if (Enum.TryParse(
+ abilityValue, ignoreCase: true,
+ out TribeAbility.Type tribeAbilityType
+ ) && tribeAbilityType == TribeAbility.Type.Flooded)
+ {
+ string tileEffectId = skinId.ToLowerInvariant() + "_flood";
+ EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
+ EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
+ Visual.customFloodingSkins.Add((TileData.EffectType)Registry.autoidx, skinValue);
+ Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx);
+ Registry.autoidx++;
+ }
+ }
+ }
+ }
+ }
+ Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values;
+ foreach (var skin in Registry.skinInfo)
+ {
+ if (modifiedSkins.Contains(skin.id))
+ {
+ modifiedSkins.Remove(skin.id);
+ modifiedSkins.Add(skin.idx.ToString(CultureInfo.InvariantCulture));
+ }
+ }
+ JArray newSkins = new JArray();
+ foreach (var item in modifiedSkins)
+ {
+ newSkins.Add(item);
+ }
+ token["skins"] = newSkins;
+ }
+ if (token["preview"] != null)
+ {
+ TribePreviews.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!;
+ Registry.tribePreviews[Util.GetJTokenName(token)] = preview;
+ }
+ }
+ })
+ },
+ [typeof(UnitData.Type)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if(!duringEnumCacheCreation)
+ {
+ if (token["embarksTo"] != null)
+ {
+ string unitId = Util.GetJTokenName(token);
+ string embarkUnitId = token["embarksTo"].ToString();
+ Main.embarkNames[unitId] = embarkUnitId;
+ }
+ if (token["weapon"] != null)
+ {
+ string weaponString = token["weapon"].ToString();
+ if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type))
+ {
+ token["weapon"] = (int)type;
+ }
+ }
+ }
+ })
+ },
+
+ [typeof(ImprovementData.Type)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if (duringEnumCacheCreation)
+ return;
+
+ if (token["prefab"] != null && (bool)token["prefab"])
+ {
+ bool levels = false;
+ if(token["maxLevel"] != null && (int)token["maxLevel"] > 0)
+ {
+ levels = true;
+ }
+ Prefabs.improvementPrefabInfosToCreate[(ImprovementData.Type)(int)token["idx"]] = levels;
+ }
+ string improvementId = Util.GetJTokenName(token);
+ if (token["attractsResource"] != null)
+ {
+ string attractsId = token["attractsResource"].ToString();
+ Main.attractsResourceNames[improvementId] = attractsId;
+ }
+ if (token["attractsToTerrain"] != null)
+ {
+ string attractsId = token["attractsToTerrain"].ToString();
+ Main.attractsTerrainNames[improvementId] = attractsId;
+ }
+ if(token["infoOverride"] != null)
+ {
+ Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString());
+ }
+ })
+ },
+
+ [typeof(ResourceData.Type)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if (duringEnumCacheCreation)
+ return;
+
+ if (token["prefab"] != null && (bool)token["prefab"])
+ {
+ Prefabs.resourcePrefabsInfosToCreate.Add((ResourceData.Type)(int)token["idx"]);
+ }
+ })
+ },
+
+ [typeof(SkinData)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ var prop = token.Parent.Cast();
+ prop.Replace(new JProperty(prop.Name.ToLowerInvariant(), prop.Value));
+ })
+ },
+ };
+
+ ///
+ /// Represents information for a custom game mode button.
+ ///
+ /// The index of the game mode.
+ /// The action to perform when the button is clicked.
+ /// The index of the button in the UI.
+ /// The sprite for the button.
+ /// The name of the sprite for the button.
+ public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName);
+
+ ///
+ /// Adds a new game mode button.
+ ///
+ /// The unique identifier for the game mode.
+ /// The action to perform when the button is clicked.
+ /// The boolean which decides if GameMode should appear in the menu.
+ /// The sprite for the button.
+ /// The name of the sprite for the button.
+ public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null)
+ {
+ EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
+ EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
+ if (shouldShowInMenu)
+ gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName));
+ Registry.gameModesAutoidx++;
+ }
+
+ ///
+ /// Adds a new data type for patching.
+ ///
+ /// The identifier for the data type in JSON.
+ /// "The C# type corresponding to the identifier.
+ public static void AddPatchDataType(string typeId, Type type)
+ {
+ if (!typeMappings.ContainsKey(typeId))
+ typeMappings.Add(typeId, new TypeMapping(type));
+ }
+
+ public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache)
+ {
+ if (!typeMappings.ContainsKey(typeId))
+ typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache));
+ }
+
+ public static void AddTypeHandler(Type type, Action handler)
+ {
+ string typeString = type.ToString();
+ if (!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false)))
+ {
+ Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first.");
+ return;
+ }
+ if (!typeHandlers.ContainsKey(type))
+ typeHandlers[type] = new();
+
+ typeHandlers[type].Add(handler);
+
+ Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}.");
+ }
+
+ ///
+ /// Loads all mods from the mods directory.
+ ///
+ /// A dictionary to populate with the loaded mods.
+ internal static void RegisterMods(Dictionary mods)
+ {
+ Directory.CreateDirectory(Plugin.MODS_PATH);
+ string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH)
+ .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod"))
+ .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip"))
+ .ToArray();
+ foreach (var modContainer in modContainers)
+ {
+ Mod.Manifest? manifest = null;
+ List files = new();
+
+ // Load mod from directory or zip archive
+ if (Directory.Exists(modContainer))
+ {
+ foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories))
+ {
+ ProcessModFile(
+ Path.GetRelativePath(modContainer, file),
+ File.ReadAllBytes(file),
+ files,
+ ref manifest
+ );
+ }
+ }
+ else
+ {
+ foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries)
+ {
+ ProcessModFile(
+ entry.FullName,
+ entry.ReadBytes(),
+ files,
+ ref manifest
+ );
+ }
+ }
+ #region ValidateManifest()
+ if (manifest == null)
+ {
+ Plugin.logger.LogError($"Mod manifest not found in {modContainer}");
+ continue;
+ }
+ if (manifest.id == null)
+ {
+ Plugin.logger.LogError($"Mod id not found in {modContainer}");
+ continue;
+ }
+ if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$"))
+ {
+ Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}");
+ continue;
+ }
+ if (Plugin.config.disabledMods.Contains(manifest.id))
+ {
+ Plugin.logger.LogError($"Mod {manifest.id} is disabled, skipping");
+ continue;
+ }
+ if (manifest.version == null)
+ {
+ Plugin.logger.LogError($"Mod version not found in {modContainer}");
+ continue;
+ }
+ if (manifest.authors == null || manifest.authors.Length == 0)
+ {
+ Plugin.logger.LogError($"Mod authors not found in {modContainer}");
+ continue;
+ }
+ if (mods.ContainsKey(manifest.id))
+ {
+ Plugin.logger.LogError($"Mod {manifest.id} already exists");
+ continue;
+ }
+ #endregion
+ mods.Add(manifest.id, new(
+ manifest,
+ Mod.Status.Success,
+ files
+ ));
+ Plugin.logger.LogInfo($"Registered mod {manifest.id}");
+ }
+
+ CheckDependencies(mods);
+ }
+
+ private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest)
+ {
+ if (fullName == "manifest.json")
+ {
+ manifest = JsonSerializer.Deserialize(
+ bytes,
+ manifestJsonOptions
+ );
+ return;
+ }
+ files.Add(new(fullName, bytes));
+ }
+
+ internal static void LoadMods(Dictionary mods, out bool dependencyCycle)
+ {
+ dependencyCycle = !SortMods(Registry.mods);
+ if (dependencyCycle) return;
+
+ foreach (var (id, mod) in Registry.mods)
+ {
+ if (mod.status != Mod.Status.Success) continue;
+ foreach (var file in mod.files)
+ {
+ if (Path.GetExtension(file.name) == ".dll")
+ {
+ LoadAssemblyFile(mod, file);
+ }
+ Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$");
+ if (spritesMatch.Success)
+ {
+ LoadSpriteInfoFile(mod, file);
+ }
+ Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$");
+ if (languageMatch.Success)
+ {
+ string languageName = languageMatch.Groups[1].Value;
+ LoadLanguageFile(
+ mod,
+ file,
+ languageName
+ );
+ }
+ }
+ }
+ Compatibility.ComputeChecksums(Registry.mods);
+ }
+
+ internal static void CheckDependencies(Dictionary mods)
+ {
+ foreach (var (id, mod) in mods)
+ {
+ foreach (var dependency in mod.dependencies ?? Array.Empty())
+ {
+ string? message = null;
+ if (!mods.TryGetValue(dependency.id, out Mod? dependencyMod))
+ {
+ message = $"Dependency {dependency.id} not found";
+ }
+ else
+ {
+ Version version = dependencyMod.version;
+ if (
+ (dependency.min != null && version < dependency.min)
+ ||
+ (dependency.max != null && version > dependency.max)
+ )
+ {
+ message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}";
+ }
+ }
+ if (message != null)
+ {
+ if (dependency.required)
+ {
+ Plugin.logger.LogError(message);
+ mod.status = Mod.Status.DependenciesUnsatisfied;
+ }
+ else
+ {
+ Plugin.logger.LogWarning(message);
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Sorts mods based on their dependencies using a topological sort.
+ ///
+ /// The dictionary of mods to sort.
+ /// True if the mods could be sorted (no circular dependencies), false otherwise.
+ internal static bool SortMods(Dictionary mods)
+ {
+ Stopwatch s = new();
+ Dictionary> graph = new();
+ Dictionary inDegree = new();
+ Dictionary successfulMods = new();
+ Dictionary unsuccessfulMods = new();
+ foreach (var (id, mod) in mods)
+ {
+ if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod);
+ else unsuccessfulMods.Add(id, mod);
+ }
+ foreach (var (id, _) in successfulMods)
+ {
+ graph[id] = new();
+ inDegree[id] = 0;
+ }
+ foreach (var (id, mod) in successfulMods)
+ {
+ foreach (var dependency in mod.dependencies ?? Array.Empty())
+ {
+ if (!graph.ContainsKey(dependency.id)) continue;
+ graph[dependency.id].Add(id);
+ inDegree[id]++;
+ }
+ }
+ Queue queue = new();
+ foreach (var (id, _) in successfulMods)
+ {
+ if (inDegree[id] == 0)
+ {
+ queue.Enqueue(id);
+ }
+ }
+ Dictionary sorted = new();
+ while (queue.Count > 0)
+ {
+ var id = queue.Dequeue();
+ var mod = successfulMods[id];
+ sorted.Add(id, mod);
+ foreach (var neighbor in graph[id])
+ {
+ inDegree[neighbor]--;
+ if (inDegree[neighbor] == 0)
+ {
+ queue.Enqueue(neighbor);
+ }
+ }
+ }
+ if (sorted.Count != successfulMods.Count)
+ {
+ return false;
+ }
+ mods.Clear();
+ mods.AddRange(sorted);
+ mods.AddRange(unsuccessfulMods);
+
+ return true;
+ }
+
+ ///
+ /// Loads an assembly file from a mod.
+ ///
+ /// The mod the assembly belongs to.
+ /// The assembly file to load.
+ public static void LoadAssemblyFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ Assembly assembly = Assembly.Load(file.bytes);
+ if (assembly
+ .GetTypes()
+ .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase)))
+ is { } modType)
+ {
+ var modInstance = (Api.PolyScriptBase)Activator.CreateInstance(modType)!;
+ modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}"));
+ modInstance.Load();
+ return;
+ }
+ foreach (Type type in assembly.GetTypes())
+ {
+ MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) });
+ if (loadWithLogger != null)
+ {
+ loadWithLogger.Invoke(null, new object[]
+ {
+ BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")
+ });
+ Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod");
+ }
+ MethodInfo? load = type.GetMethod("Load", Array.Empty());
+ if (load != null)
+ {
+ load.Invoke(null, null);
+ Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod");
+ }
+ }
+ }
+ catch (TargetInvocationException exception)
+ {
+ if (exception.InnerException != null)
+ {
+ Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}");
+ mod.status = Mod.Status.Error;
+ }
+ }
+ }
+
+ ///
+ /// Loads a localization file from a mod.
+ ///
+ /// The mod the localization file belongs to.
+ /// The localization file to load.
+ public static void LoadLocalizationFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ Loc.BuildAndLoadLocalization(JsonSerializer
+ .Deserialize>>(file.bytes)!);
+ Plugin.logger.LogInfo($"Registered localization from {mod.id} mod");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}");
+ }
+ }
+
+ public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName)
+ {
+ try
+ {
+ var dict = JsonSerializer.Deserialize>(file.bytes);
+ if (dict != null)
+ {
+ Loc.languagesToAdd.Add(languageName, dict);
+ Plugin.logger.LogInfo($"Registered language from {mod.id} mod");
+ }
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}");
+ }
+ }
+
+ ///
+ /// Loads a sprite file from a mod.
+ ///
+ /// The mod the sprite file belongs to.
+ /// The sprite file to load.
+ public static void LoadTextureFile(Mod mod, Mod.File file)
+ {
+ string name = Path.GetFileNameWithoutExtension(file.name);
+ Texture2D texture2D = Visual.BuildTexture(file.bytes);
+ Registry.textures[name] = new KeyValuePair(texture2D, false);
+ }
+
+ ///
+ /// Loads a sprite info file from a mod.
+ ///
+ /// The mod the sprite info file belongs to.
+ /// The sprite info file to load.
+ /// A dictionary of sprite information, or null if an error occurred.
+ public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var deserialized = JsonSerializer.Deserialize>(
+ file.bytes,
+ spriteInfoJsonOptions
+ );
+
+ if (deserialized != null)
+ {
+ foreach (var kvp in deserialized)
+ {
+ Registry.spriteInfos[kvp.Key] = kvp.Value;
+ }
+ }
+
+ Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod");
+ return deserialized;
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.StackTrace}");
+ return null;
+ }
+ }
+
+ ///
+ /// Loads an audio file from a mod.
+ ///
+ /// The mod the audio file belongs to.
+ /// The audio file to load.
+ public static void LoadAudioFile(Mod mod, Mod.File file)
+ {
+ string name = Path.GetFileNameWithoutExtension(file.name).ToLowerInvariant();
+ try
+ {
+ AudioClip clip = Managers.Audio.BuildAudioClip(file.bytes, name);
+ AudioSource audioSource = new GameObject($"PolyModAudio_{name}").AddComponent();
+ UnityEngine.Object.DontDestroyOnLoad(audioSource.gameObject);
+ audioSource.clip = clip;
+ Registry.audioClips[name] = audioSource;
+
+ Plugin.logger.LogInfo(
+ $"Registered audio {name} file from {mod.id} mod"
+ + $" ({clip.channels}ch {clip.frequency}Hz {clip.length:F2}s)."
+ );
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading audio {name} file from {mod.id} mod: {e.Message}");
+ }
+ }
+
+ public static void LoadPrefabsFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var prefab = JsonSerializer.Deserialize(file.bytes, prefabJsonOptions);
+ if (prefab == null)
+ return;
+
+ Prefabs.unitInfos.AddRange(prefab.units);
+ Prefabs.improvementInfos.AddRange(prefab.improvements);
+ Prefabs.resourceInfos.AddRange(prefab.resources);
+
+ Plugin.logger.LogInfo($"Registered prefabs {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading prefabs {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ public static void LoadUnitPrefabFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var unitPrefab = JsonSerializer.Deserialize(file.bytes, prefabJsonOptions);
+ if (unitPrefab == null)
+ return;
+
+ Prefabs.unitInfos.Add(unitPrefab);
+
+ Plugin.logger.LogInfo($"Registered unit prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading unit prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ public static void LoadImprovementPrefabFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var improvementPrefab = JsonSerializer.Deserialize(file.bytes, prefabJsonOptions);
+ if (improvementPrefab == null)
+ return;
+
+ Prefabs.improvementInfos.Add(improvementPrefab);
+
+ Plugin.logger.LogInfo($"Registered improvement prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading improvement prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ public static void LoadResourcePrefabFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var resourcePrefab = JsonSerializer.Deserialize(file.bytes, prefabJsonOptions);
+ if (resourcePrefab == null)
+ return;
+
+ Prefabs.resourceInfos.Add(resourcePrefab);
+
+ Plugin.logger.LogInfo($"Registered resource prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading resource prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ ///
+ /// Loads and applies a game logic data patch from a mod.
+ ///
+ /// The mod the patch belongs to.
+ /// The original game logic data.
+ /// The patch to apply.
+ public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch)
+ {
+ try
+ {
+ // Merge the patch into the game logic data
+ gld = JsonMerger.Merge(gld, patch);
+ Plugin.logger.LogInfo($"Registered patch from {mod.id} mod");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}");
+ mod.status = Mod.Status.Error;
+ }
+ }
+
+ ///
+ /// Loads an asset bundle from a mod.
+ ///
+ /// The mod the asset bundle belongs to.
+ /// The asset bundle file to load.
+ public static void LoadAssetBundle(Mod mod, Mod.File file)
+ {
+ Registry.assetBundles.Add(
+ Path.GetFileNameWithoutExtension(file.name),
+ AssetBundle.LoadFromMemory(file.bytes)
+ );
+ }
+
+ ///
+ /// Processes the merged game logic data after all mods have been loaded and patched.
+ ///
+ /// The game logic data object to populate.
+ /// The root JObject of the merged game logic data.
+ internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject)
+ {
+ try
+ {
+ CreateMappings(rootObject);
+ ProcessCustomTribes();
+ ProcessEmbarkOverrides();
+ ProcessAttractOverrides();
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}");
+ }
+ }
+
+ internal static void ProcessCustomTribes()
+ {
+ foreach (var tribe in Registry.customTribes)
+ {
+ if (!GameLogicData.legacyTribeTypesOrder.Contains(tribe))
+ {
+ GameLogicData.legacyTribeTypesOrder.Add(tribe);
+ }
+ }
+ }
+
+ ///
+ /// Creates EnumCache mappings for custom enum values and invokes type handlers.
+ ///
+ ///
+ internal static void CreateMappings(JObject rootObject)
+ {
+ foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
+ {
+ JObject? token = jtoken.TryCast();
+ if (token == null)
+ continue;
+
+ string dataType = Util.GetJTokenName(token, 2);
+ if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
+ continue;
+
+ if (token["idx"] == null || !typeMapping.shouldCreateCache)
+ continue;
+
+ Type targetType = typeMapping.type;
+ if (!targetType.IsEnum)
+ {
+ Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!");
+ continue;
+ }
+
+ string id = Util.GetJTokenName(token);
+
+ if ((int)token["idx"] == -1)
+ {
+ token["idx"] = Registry.autoidx;
+ Registry.autoidx++;
+ }
+ else if (Plugin.config.allowUnsafeIndexes)
+ {
+ Array values = Enum.GetValues(targetType);
+
+ var maxValue = values.Cast().Max();
+
+ if (maxValue >= (int)token["idx"])
+ {
+ continue;
+ }
+ }
+ else
+ {
+ continue;
+ }
+
+ MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping");
+ if (methodInfo == null)
+ {
+ Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}");
+ continue;
+ }
+
+ methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
+ methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
+
+ if (typeHandlers.TryGetValue(targetType, out var handlers))
+ {
+ foreach (var handler in handlers)
+ {
+ handler(token, true);
+ }
+ }
+ Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]);
+ }
+ foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
+ {
+ JObject? token = jtoken.TryCast();
+ if (token != null)
+ {
+ string dataType = Util.GetJTokenName(token, 2);
+ if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
+ {
+ if (typeHandlers.TryGetValue(typeMapping.type, out var handlers))
+ {
+ foreach (var handler in handlers)
+ {
+ handler(token, false);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Processes the prefab registry and populates the PrefabManager with custom prefabs.
+ ///
+ internal static void ProcessFilesQueue()
+ {
+ try
+ {
+ foreach (KeyValuePair keyValuePair in Registry.filesQueueToBeProcessedAfterGLDMerge)
+ {
+ Mod mod = keyValuePair.Key;
+ Mod.File file = keyValuePair.Value;
+ Match prefabsMatch = Regex.Match(Path.GetFileName(file.name), @"^prefabs(?:_(.*))?\.json$");
+ if (prefabsMatch.Success)
+ {
+ LoadPrefabsFile(mod, file);
+ continue;
+ }
+
+ Match unitPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^unitPrefab(?:_(.*))?\.json$");
+ if (unitPrefabMatch.Success)
+ {
+ LoadUnitPrefabFile(mod, file);
+ continue;
+ }
+
+ Match improvementPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^improvementPrefab(?:_(.*))?\.json$");
+ if (improvementPrefabMatch.Success)
+ {
+ LoadImprovementPrefabFile(mod, file);
+ continue;
+ }
+
+ Match resourcePrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^resourcePrefab(?:_(.*))?\.json$");
+ if (resourcePrefabMatch.Success)
+ {
+ LoadResourcePrefabFile(mod, file);
+ continue;
+ }
+
+ }
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on processing post gld merge files queue : {e.StackTrace}");
+ }
+ }
+
+ ///
+ /// Processes embark overrides by mapping original embark unit types to configured overrides.
+ ///
+ internal static void ProcessEmbarkOverrides()
+ {
+ foreach (KeyValuePair entry in Main.embarkNames)
+ {
+ try
+ {
+ UnitData.Type unit = EnumCache.GetType(entry.Key);
+ UnitData.Type newUnit = EnumCache.GetType(entry.Value);
+ Main.embarkOverrides[unit] = newUnit;
+ Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}");
+ }
+ catch
+ {
+ Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}");
+ }
+ }
+ }
+
+ ///
+ /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides.
+ ///
+ internal static void ProcessAttractOverrides()
+ {
+ foreach (KeyValuePair entry in Main.attractsResourceNames)
+ {
+ try
+ {
+ ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
+ ResourceData.Type resource = EnumCache.GetType(entry.Value);
+ Main.attractsResourceOverrides[improvement] = resource;
+ Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}");
+ }
+ catch
+ {
+ Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}");
+ }
+ }
+ foreach (KeyValuePair entry in Main.attractsTerrainNames)
+ {
+ try
+ {
+ ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
+ Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value);
+ Main.attractsTerrainOverrides[improvement] = terrain;
+ Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}");
+ }
+ catch
+ {
+ Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}");
+ }
+ }
+ }
}
diff --git a/src/Managers/Audio.cs b/src/Managers/Audio.cs
index 100cec7..fa629a0 100644
--- a/src/Managers/Audio.cs
+++ b/src/Managers/Audio.cs
@@ -1,5 +1,4 @@
using HarmonyLib;
-using Polytopia.Data;
using UnityEngine;
using PolytopiaBackendBase.Common;
@@ -10,73 +9,101 @@ namespace PolyMod.Managers;
///
public static class Audio
{
- ///
- /// Patches the music data to get custom nature audio clips.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetNatureAudioClip))]
- private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeType type, SkinType skinType)
- {
- AudioClip? audioClip = Registry.GetAudioClip("nature", Util.GetStyle(type, skinType));
- if (audioClip != null)
- {
- __result = audioClip;
- return false;
- }
- return true;
- }
+ ///
+ /// Patches the music data to get custom nature audio clips.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetNatureAudioClip))]
+ private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeType type, SkinType skinType)
+ {
+ AudioClip? audioClip = Registry.GetAudioClip("nature", type, skinType);
+ if (audioClip != null)
+ {
+ __result = audioClip;
+ return false;
+ }
+ return true;
+ }
- ///
- /// Patches the music data to get custom music audio clips.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetMusicAudioClip))]
- private static bool MusicData_GetMusicAudioClip(ref AudioClip __result, TribeType type, SkinType skinType)
- {
- AudioClip? audioClip = Registry.GetAudioClip("music", Util.GetStyle(type, skinType));
- if (audioClip != null)
- {
- __result = audioClip;
- return false;
- }
- return true;
- }
+ ///
+ /// Patches the music data to get custom music audio clips.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetMusicAudioClip))]
+ private static bool MusicData_GetMusicAudioClip(ref AudioClip __result, TribeType type, SkinType skinType)
+ {
+ AudioClip? audioClip = Registry.GetAudioClip("music", type, skinType);
+ if (audioClip != null)
+ {
+ __result = audioClip;
+ return false;
+ }
+ return true;
+ }
- ///
- /// Patches the audio SFX data to get custom sound effect clips.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(AudioSFXData), nameof(AudioSFXData.GetClip))]
- private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, SkinType skinType)
- {
- AudioClip? audioClip = Registry.GetAudioClip(
- EnumCache.GetName(id),
- EnumCache.GetName(skinType)
- );
- if (audioClip != null)
- {
- __result = audioClip;
- return false;
- }
- return true;
- }
+ ///
+ /// Patches the audio SFX data to get custom sound effect clips.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(AudioSFXData), nameof(AudioSFXData.GetClip))]
+ private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, SkinType skinType)
+ {
+ AudioClip? audioClip = Registry.GetAudioClip(
+ EnumCache.GetName(id),
+ skin: skinType
+ );
+ if (audioClip != null)
+ {
+ __result = audioClip;
+ return false;
+ }
+ return true;
+ }
- ///
- /// Builds an audio clip from raw byte data.
- ///
- /// The byte data of the audio file.
- /// The created audio clip.
- public static AudioClip BuildAudioClip(byte[] data)
- {
- // TODO: This is a placeholder. The actual implementation is tracked in issue #71.
- return new AudioClip(new());
- }
+ private const string CLIP_CREATION_UNAVAILABLE =
+ "this game build cannot create audio clips at runtime."
+ + " AudioClip.Create marshals its arguments as spans, and"
+ + " Il2CppSystem.ReadOnlySpan.GetPinnableReference() is missing from its IL2CPP build."
+ + " Custom audio stays disabled until the game ships calling AudioClip.Create and SetData";
- ///
- /// Initializes the Audio manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(Audio));
- }
+ private static bool clipCreationUnavailable = false;
+
+ ///
+ /// Builds an audio clip from raw byte data.
+ ///
+ /// The byte data of the audio file, which has to be a WAVE file.
+ /// The name to give the clip inside Unity.
+ /// The created audio clip.
+ /// The data is not a WAVE file that can be decoded.
+ /// The game build cannot create audio clips.
+ public static AudioClip BuildAudioClip(byte[] data, string name = "polymod_audio")
+ {
+ WavDecoder.WavData wav = WavDecoder.Decode(data);
+ if (clipCreationUnavailable)
+ throw new NotSupportedException($"Cannot build {name}, {CLIP_CREATION_UNAVAILABLE}.");
+
+ try
+ {
+ AudioClip clip = AudioClip.Create(name, wav.lengthSamples, wav.channels, wav.frequency, false);
+ if (clip == null)
+ throw new InvalidOperationException($"Unity returned no audio clip for {name}.");
+ if (!clip.SetData(wav.samples, 0))
+ throw new InvalidOperationException($"Unity rejected the samples for {name}.");
+ return clip;
+ }
+ catch (MissingMethodException exception)
+ {
+ // Remembered so that a mod shipping many files reports this once rather than per file.
+ clipCreationUnavailable = true;
+ throw new NotSupportedException($"Cannot build {name}, {CLIP_CREATION_UNAVAILABLE}.", exception);
+ }
+ }
+
+ ///
+ /// Initializes the Audio manager by patching the necessary methods.
+ ///
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Audio));
+ }
}
diff --git a/src/Managers/AutoUpdate.cs b/src/Managers/AutoUpdate.cs
deleted file mode 100644
index 58ea639..0000000
--- a/src/Managers/AutoUpdate.cs
+++ /dev/null
@@ -1,182 +0,0 @@
-using System.Diagnostics;
-using System.IO.Compression;
-using System.Text.Json;
-using HarmonyLib;
-using UnityEngine;
-
-namespace PolyMod.Managers;
-
-///
-/// Manages the automatic update process for PolyMod.
-///
-internal static class AutoUpdate
-{
- ///
- /// Checks for updates when the start screen is shown.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))]
- private static void StartScreen_UI2_OnShow()
- {
- if (!Plugin.config.autoUpdate) return;
- if (Environment.GetEnvironmentVariable("WINEPREFIX") != null)
- {
- Plugin.logger.LogError("Autoupdate is not supported on Wine!");
- return;
- }
- HttpClient client = new();
- client.DefaultRequestHeaders.Add("User-Agent", "PolyMod");
- try
- {
- // Fetch release information from GitHub API
- var json = JsonDocument.Parse(
- client.GetAsync("https://api.github.com/repos/PolyModdingTeam/PolyMod/releases").UnwrapAsync()
- .Content.ReadAsStringAsync().UnwrapAsync()
- );
- JsonElement? latest = null;
- for (int i = 0; i < json.RootElement.GetArrayLength(); i++)
- {
- var release = json.RootElement[i];
- if (release.GetProperty("prerelease").GetBoolean() && !Plugin.config.updatePrerelease) continue;
- latest = release;
- break;
- }
- string newVersion = latest?.GetProperty("tag_name").GetString()!.TrimStart('v')!;
- if (newVersion.IsVersionOlderOrEqual(Plugin.VERSION)) return;
-
- // Determine the correct BepInEx URL for the current OS
- string os = Application.platform switch
- {
- RuntimePlatform.WindowsPlayer => "win",
- RuntimePlatform.LinuxPlayer => "linux",
- RuntimePlatform.OSXPlayer => "macos",
- _ => "unknown",
- };
- if (os == "unknown")
- {
- Plugin.logger.LogError("Unsupported platform for autoupdate!");
- return;
- }
- string bepinex_url = client
- .GetAsync("https://polymod.dev/data/bepinex.txt").UnwrapAsync()
- .Content.ReadAsStringAsync().UnwrapAsync()
- .Replace("{os}", os);
-
- // The actual update logic
- void Update()
- {
- Time.timeScale = 0;
-
- // Download new PolyMod DLL and BepInEx files
- File.WriteAllBytes(
- Path.Combine(Plugin.BASE_PATH, "PolyMod.new.dll"),
- client.GetAsync(latest?.GetProperty("assets")[0].GetProperty("browser_download_url").GetString()!).UnwrapAsync()
- .Content.ReadAsByteArrayAsync().UnwrapAsync()
- );
- using ZipArchive bepinex = new(client.GetAsync(bepinex_url).UnwrapAsync().Content.ReadAsStream());
- bepinex.ExtractToDirectory(Path.Combine(Plugin.BASE_PATH, "New"), overwriteFiles: true);
-
- // Create and run the appropriate update script for the OS
- ProcessStartInfo info = new()
- {
- WorkingDirectory = Path.Combine(Plugin.BASE_PATH),
- CreateNoWindow = true,
- };
- if (Application.platform == RuntimePlatform.WindowsPlayer)
- {
- string batchPath = Path.Combine(Plugin.BASE_PATH, "update.bat");
- File.WriteAllText(batchPath, $@"
- @echo off
- echo Waiting for Polytopia.exe to exit...
- :waitloop
- tasklist | findstr /I ""Polytopia.exe"" >nul
- if not errorlevel 1 (
- timeout /T 1 >nul
- goto waitloop
- )
-
- echo Updating...
- robocopy ""New"" . /E /MOVE /NFL /NDL /NJH /NJS /NP >nul
- rmdir /S /Q ""New""
- del /F /Q ""BepInEx\plugins\PolyMod.dll""
- move /Y ""PolyMod.new.dll"" ""BepInEx\plugins\PolyMod.dll""
-
- echo Launching game...
- start steam://rungameid/874390
- timeout /T 3 /NOBREAK >nul
- exit
- ");
- info.FileName = "cmd.exe";
- info.Arguments = $"/C start \"\" \"{batchPath}\"";
- info.WorkingDirectory = Plugin.BASE_PATH;
- info.CreateNoWindow = true;
- info.UseShellExecute = false;
- }
- if (Application.platform == RuntimePlatform.LinuxPlayer || Application.platform == RuntimePlatform.OSXPlayer)
- {
- string bashPath = Path.Combine(Plugin.BASE_PATH, "update.sh");
- File.WriteAllText(bashPath, $@"
- #!/bin/bash
-
- echo ""Waiting for Polytopia to exit...""
- while pgrep -x ""Polytopia"" > /dev/null; do
- sleep 1
- done
-
- echo ""Updating...""
- mv New/* . && rm -rf New
- rm -f BepInEx/plugins/PolyMod.dll
- mv -f PolyMod.new.dll BepInEx/plugins/PolyMod.dll
-
- echo ""Launching game...""
- xdg-open steam://rungameid/874390 &
-
- sleep 3
- exit 0
- ");
-
- System.Diagnostics.Process chmod = new System.Diagnostics.Process();
- chmod.StartInfo.FileName = "chmod";
- chmod.StartInfo.Arguments = $"+x \"{bashPath}\"";
- chmod.StartInfo.UseShellExecute = false;
- chmod.StartInfo.CreateNoWindow = true;
- chmod.Start();
- chmod.WaitForExit();
-
- info.FileName = "/bin/bash";
- info.Arguments = $"\"{bashPath}\"";
- info.WorkingDirectory = Plugin.BASE_PATH;
- info.CreateNoWindow = true;
- info.UseShellExecute = false;
- }
- Process.Start(info);
- Application.Quit();
- }
-
- // Show a popup to the user asking if they want to update
- PopupManager.GetBasicPopupWithData(new(
- Localization.Get("polymod.autoupdate"),
- Localization.Get("polymod.autoupdate.description"),
- new(new PopupBase.PopupButtonData[] {
- new(
- "polymod.autoupdate.update",
- PopupBase.PopupButtonData.States.None,
- (Il2CppSystem.Action)Update
- )
- }))
- ).Show();
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Failed to check updates: {e.Message}");
- }
- }
-
- ///
- /// Initializes the AutoUpdate manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(AutoUpdate));
- }
-}
\ No newline at end of file
diff --git a/src/Managers/Compatibility.cs b/src/Managers/Compatibility.cs
index 1d55bb7..8083f05 100644
--- a/src/Managers/Compatibility.cs
+++ b/src/Managers/Compatibility.cs
@@ -1,7 +1,6 @@
using HarmonyLib;
-using System.Text;
+using Il2CppInterop.Runtime;
using UnityEngine;
-using UnityEngine.EventSystems;
namespace PolyMod.Managers;
@@ -10,227 +9,360 @@ namespace PolyMod.Managers;
///
internal static class Compatibility
{
- ///
- /// The checksum of all loaded mods.
- ///
- internal static string checksum = string.Empty;
-
- ///
- /// Whether the game settings should be reset due to a change in mods.
- ///
- internal static bool shouldResetSettings = false;
- private static bool sawSignatureWarning;
-
- ///
- /// Hashes the signatures of all loaded mods to create a checksum.
- ///
- /// A string builder containing the signatures to hash.
- public static void HashSignatures(StringBuilder checksumString)
- {
- checksum = Util.Hash(checksumString);
- }
-
- ///
- /// Checks the signature of a saved game to ensure it is compatible with the current mods.
- ///
- /// True if the signatures match or if the check is ignored, false otherwise.
- private static bool CheckSignatures(Action action, Il2CppSystem.Guid gameId)
- {
- if (sawSignatureWarning)
- {
- sawSignatureWarning = false;
- return true;
- }
-
- string stateChecksum = string.Empty;
- try
- {
- Plugin.logger.LogInfo($"Getting checksum for state {gameId}");
- stateChecksum = File.ReadAllText(Path.Combine(Application.persistentDataPath, $"{gameId}.signatures"));
- Plugin.logger.LogInfo($"Checksum found.");
- }
- catch
- {
- Plugin.logger.LogInfo($"Failed to get checksum.");
- }
- if (stateChecksum == string.Empty)
- {
- Plugin.logger.LogInfo($"State checksum is empty, ignoring.");
- return true;
- }
- bool doChecksumsMatch = stateChecksum == checksum;
- Plugin.logger.LogInfo($"State checksum: '{stateChecksum}', global checksum: '{checksum}', comparison result : {doChecksumsMatch}");
- if (Plugin.config.debug)
- {
- Plugin.logger.LogInfo($"Debug detected, ignoring.");
- return true;
- }
- if (!doChecksumsMatch)
- {
- PopupManager.GetBasicPopupWithData(new(
- Localization.Get("polymod.signature.mismatch"),
- Localization.Get("polymod.signature.incompatible"),
- new(new PopupBase.PopupButtonData[] {
- new("OK")
- })
- )).Show();
-
- return false;
- }
- return true;
- }
-
- ///
- /// Performs compatibility checks when the start screen is shown.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))]
- private static void StartScreen_UI2_OnShow()
- {
- string lastChecksum = checksum;
- try
- {
- lastChecksum = new(File.ReadAllText(Plugin.CHECKSUM_PATH));
- }
- catch (FileNotFoundException) { }
-
- File.WriteAllText(
- Plugin.CHECKSUM_PATH,
- checksum
- );
- if (lastChecksum != checksum)
- {
- shouldResetSettings = true;
- }
-
- Version incompatibilityWarningLastVersion = new(PlayerPrefs.GetString(
- Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY,
- Plugin.POLYTOPIA_VERSION.CutRevision().ToString()
- ));
- if (VersionManager.SemanticVersion.Cast().CutRevision() > incompatibilityWarningLastVersion)
- {
- PlayerPrefs.SetString(
- Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY,
- VersionManager.SemanticVersion.Cast().CutRevision().ToString()
- );
- PlayerPrefs.Save();
- PopupManager.GetBasicPopupWithData(
- new(
- Localization.Get("polymod.version.mismatch"),
- Localization.Get("polymod.version.mismatch.description"),
- new PopupBase.PopupButtonData[] {
- new("buttons.stay", customColorStates: ColorConstants.redButtonColorStates),
- new(
- "buttons.exitgame",
- PopupBase.PopupButtonData.States.None,
- (Il2CppSystem.Action)Application.Quit,
- closesPopup: false
- )
- }
- )
- ).Show();
- }
- }
-
- ///
- /// Checks the signature of a pass-and-play game before loading it.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.OnMainButtonClicked))]
- private static bool GameInfoPopup_OnMainButtonClicked(GameInfoPopup __instance)
- {
- return CheckSignatures(__instance.OnMainButtonClicked, __instance.gameId);
- }
-
- ///
- /// Checks the signature of a single-player game before resuming it.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnResumeButtonLongPress))]
- private static bool StartScreen_OnResumeButtonClick(StartScreen_UI2 __instance)
- {
- return CheckSignatures(__instance.OnResumeButtonLongPress, LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)[0]);
- }
-
- ///
- /// Deletes the signature file of a pass-and-play game when it is deleted.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.DeletePaPGame))]
- private static void GameInfoPopup_DeletePaPGame(GameInfoPopup __instance)
- {
- File.Delete(Path.Combine(Application.persistentDataPath, $"{__instance.gameId}.signatures"));
- }
-
- ///
- /// Deletes the signature file of all singleplayer games when they are deleted.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(LocalSaveFileUtils), nameof(LocalSaveFileUtils.DeleteAllSaveFilesOfType))]
- private static void LocalSaveFileUtils_DeleteAllSaveFilesOfType(PolytopiaBackendBase.Game.GameType gameType, bool localOnly)
- {
- if (gameType == PolytopiaBackendBase.Game.GameType.SinglePlayer)
- {
- foreach (var gameId in LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer))
- {
- File.Delete(Path.Combine(Application.persistentDataPath, $"{gameId}.signatures"));
- }
- }
- }
-
- ///
- /// Deletes the signature file of a game when the match ends.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(GameManager), nameof(GameManager.MatchEnded))]
- private static void GameManager_MatchEnded(bool localPlayerIsWinner, ScoreDetails scoreDetails, byte winnerId)
- {
- File.Delete(Path.Combine(Application.persistentDataPath, $"{GameManager.Client.gameId}.signatures"));
- }
-
- ///
- /// Creates a signature file when a new game session is created.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(ClientBase), nameof(ClientBase.CreateSession), typeof(GameSettings), typeof(Il2CppSystem.Guid))]
- private static void ClientBase_CreateSession(GameSettings settings, Il2CppSystem.Guid gameId)
- {
- File.WriteAllTextAsync(
- Path.Combine(Application.persistentDataPath, $"{gameId}.signatures"),
- checksum
- );
- }
-
- ///
- /// Resets game settings if necessary when the tribe selector screen is shown.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(TribeSelectorScreen), nameof(TribeSelectorScreen.Show))]
- private static bool TribeSelectorScreen_Show(bool instant = false)
- {
- if (shouldResetSettings)
- {
- RestorePreliminaryGameSettings();
- shouldResetSettings = false;
- }
- return true;
- }
-
- ///
- /// Restores the preliminary game settings to their default values.
- ///
- internal static void RestorePreliminaryGameSettings()
- {
- GameManager.PreliminaryGameSettings.disabledTribes.Clear();
- GameManager.PreliminaryGameSettings.selectedSkins.Clear();
- GameManager.PreliminaryGameSettings.SaveToDisk();
- }
-
- ///
- /// Initializes the Compatibility manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(Compatibility));
- }
+ ///
+ /// The checksum of all loaded mods.
+ ///
+ internal static string checksum = string.Empty;
+
+ ///
+ /// Whether the game settings should be reset due to a change in mods.
+ ///
+ internal static bool shouldResetSettings = false;
+ private static bool sawSignatureWarning;
+ private const string downloadLink = "https://polymod.dev/download/";
+
+ public static void ComputeChecksums(Dictionary mods)
+ {
+ foreach (var (_, mod) in mods)
+ {
+ mod.checksum = ComputeModChecksum(mod);
+ }
+
+ List signatures = new();
+ foreach (var (id, mod) in mods)
+ {
+ if (mod.status != Mod.Status.Success) continue;
+ if (mod.client || id == "polytopia") continue;
+
+ signatures.Add($"{id}:{mod.checksum}");
+ }
+ signatures.Sort(StringComparer.Ordinal);
+
+ checksum = Util.Hash(string.Join("\n", signatures));
+ }
+
+ public static string ComputeModChecksum(Mod mod)
+ {
+ List entries = new();
+ foreach (Mod.File file in mod.files)
+ {
+ string name = file.name.Replace('\\', '/');
+ if (name.EndsWith('/')) continue;
+
+ entries.Add($"{name}:{Util.HashBytes(file.bytes)}");
+ }
+ entries.Sort(StringComparer.Ordinal);
+
+ return Util.Hash($"{mod.id}:{mod.version}\n{string.Join("\n", entries)}");
+ }
+
+ ///
+ /// Gets the player prefs key holding the checksum a saved game was created with.
+ ///
+ /// The id of the saved game.
+ /// The player prefs key.
+ private static string StateChecksumKey(object gameId)
+ {
+ return Plugin.STATE_CHECKSUM_KEY_PREFIX + NormalizeGameId(gameId);
+ }
+
+ ///
+ /// Normalizes a game id so that the same save always maps to one key. The game hands ids over
+ /// as a Guid in some places and as a string in others, and those do not always match in case
+ /// or in whether they are wrapped in braces.
+ ///
+ /// The id of the saved game, as a Guid or a string.
+ /// The normalized id.
+ internal static string NormalizeGameId(object gameId)
+ {
+ return gameId.ToString()!.Trim('{', '}').ToLowerInvariant();
+ }
+
+ ///
+ /// Gets the path of the signature file older versions wrote for a saved game.
+ ///
+ /// The id of the saved game.
+ /// The path of the legacy signature file.
+ private static string LegacyStateChecksumPath(object gameId)
+ {
+ return Path.Combine(Application.persistentDataPath, $"{NormalizeGameId(gameId)}.signatures");
+ }
+
+ ///
+ /// Stores the current mod checksum for a saved game.
+ ///
+ /// The id of the saved game.
+ internal static void SaveStateChecksum(object gameId)
+ {
+ PlayerPrefs.SetString(StateChecksumKey(gameId), checksum);
+ PlayerPrefs.Save();
+ }
+
+ ///
+ /// Reads the mod checksum a saved game was created with, migrating it out of the
+ /// signature file older versions used.
+ ///
+ /// The id of the saved game.
+ /// The stored checksum, or an empty string when there is none.
+ internal static string ReadStateChecksum(object gameId)
+ {
+ string key = StateChecksumKey(gameId);
+ if (PlayerPrefs.HasKey(key))
+ return PlayerPrefs.GetString(key);
+
+ string legacyPath = LegacyStateChecksumPath(gameId);
+ if (!File.Exists(legacyPath))
+ return string.Empty;
+
+ string legacyChecksum = File.ReadAllText(legacyPath);
+ PlayerPrefs.SetString(key, legacyChecksum);
+ PlayerPrefs.Save();
+ File.Delete(legacyPath);
+ return legacyChecksum;
+ }
+
+ internal static void DeleteStateChecksum(object gameId)
+ {
+ PlayerPrefs.DeleteKey(StateChecksumKey(gameId));
+ PlayerPrefs.Save();
+
+ string legacyPath = LegacyStateChecksumPath(gameId);
+ if (File.Exists(legacyPath))
+ File.Delete(legacyPath);
+ }
+
+ ///
+ /// Checks the signature of a saved game to ensure it is compatible with the current mods.
+ ///
+ /// True if the signatures match or if the check is ignored, false otherwise.
+ private static bool CheckSignatures(Action action, Il2CppSystem.Guid gameId)
+ {
+ if (sawSignatureWarning)
+ {
+ sawSignatureWarning = false;
+ return true;
+ }
+
+ string stateChecksum = string.Empty;
+ try
+ {
+ Plugin.logger.LogInfo($"Getting checksum for state {gameId}");
+ stateChecksum = ReadStateChecksum(gameId);
+ Plugin.logger.LogInfo($"Checksum found.");
+ }
+ catch
+ {
+ Plugin.logger.LogInfo($"Failed to get checksum.");
+ }
+ if (stateChecksum == string.Empty)
+ {
+ Plugin.logger.LogInfo($"State checksum is empty, ignoring.");
+ return true;
+ }
+ bool doChecksumsMatch = stateChecksum == checksum;
+ Plugin.logger.LogInfo($"State checksum: '{stateChecksum}', global checksum: '{checksum}', comparison result : {doChecksumsMatch}");
+ if (Plugin.config.debug)
+ {
+ Plugin.logger.LogInfo($"Debug detected, ignoring.");
+ return true;
+ }
+ if (!doChecksumsMatch)
+ {
+ PopupManager.GetBasicPopupWithData(new(
+ Localization.Get("polymod.signature.mismatch"),
+ Localization.Get("polymod.signature.incompatible"),
+ new(new PopupBase.PopupButtonData[] {
+ new("OK")
+ })
+ )).Show();
+
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Performs compatibility checks when the start screen is shown.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))]
+ private static void StartScreen_UI2_OnShow()
+ {
+ if (!Plugin.ValidLaunch && Application.platform != RuntimePlatform.Android)
+ {
+ PopupManager.GetBasicPopupWithData(
+ new(
+ Localization.Get("polymod.title.invalid"),
+ Localization.Get("polymod.description.invalid"),
+ new PopupBase.PopupButtonData[] {
+ new(
+ "buttons.exitgame",
+ PopupBase.PopupButtonData.States.None,
+ callback: (Il2CppSystem.Action)Application.Quit,
+ closesPopup: false
+ ),
+ new(
+ "buttons.download.launcher",
+ PopupBase.PopupButtonData.States.None,
+ callback: DelegateSupport.ConvertDelegate(OpenSite),
+ closesPopup: false
+ )
+ }
+ )
+ ).Show();
+
+ void OpenSite()
+ {
+ NativeHelpers.OpenURL(downloadLink, false);
+ Application.Quit();
+ }
+ return;
+ }
+ string lastChecksum = checksum;
+ try
+ {
+ lastChecksum = new(File.ReadAllText(Plugin.CHECKSUM_PATH));
+ }
+ catch (FileNotFoundException) { }
+
+ File.WriteAllText(
+ Plugin.CHECKSUM_PATH,
+ checksum
+ );
+ if (lastChecksum != checksum)
+ {
+ shouldResetSettings = true;
+ }
+
+ Version incompatibilityWarningLastVersion = new(PlayerPrefs.GetString(
+ Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY,
+ Plugin.POLYTOPIA_VERSION.CutRevision().ToString()
+ ));
+ if (VersionManager.SemanticVersion.Cast().CutRevision() > incompatibilityWarningLastVersion)
+ {
+ PlayerPrefs.SetString(
+ Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY,
+ VersionManager.SemanticVersion.Cast().CutRevision().ToString()
+ );
+ PlayerPrefs.Save();
+ PopupManager.GetBasicPopupWithData(
+ new(
+ Localization.Get("polymod.version.mismatch"),
+ Localization.Get("polymod.version.mismatch.description"),
+ new PopupBase.PopupButtonData[] {
+ new("buttons.stay", customColorStates: ColorConstants.redButtonColorStates),
+ new(
+ "buttons.exitgame",
+ PopupBase.PopupButtonData.States.None,
+ (Il2CppSystem.Action)Application.Quit,
+ closesPopup: false
+ )
+ }
+ )
+ ).Show();
+ }
+ }
+
+ ///
+ /// Checks the signature of a pass-and-play game before loading it.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.OnMainButtonClicked))]
+ private static bool GameInfoPopup_OnMainButtonClicked(GameInfoPopup __instance)
+ {
+ return CheckSignatures(__instance.OnMainButtonClicked, __instance.gameId);
+ }
+
+ ///
+ /// Checks the signature of a single-player game before resuming it.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnResumeButtonLongPress))]
+ private static bool StartScreen_OnResumeButtonClick(StartScreen_UI2 __instance)
+ {
+ return CheckSignatures(__instance.OnResumeButtonLongPress, LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)[0]);
+ }
+
+ ///
+ /// Deletes the stored checksum of a pass-and-play game when it is deleted.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.DeletePaPGame))]
+ private static void GameInfoPopup_DeletePaPGame(GameInfoPopup __instance)
+ {
+ DeleteStateChecksum(__instance.gameId);
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(LocalSaveFileUtils), nameof(LocalSaveFileUtils.DeleteSaveFile))]
+ private static void LocalSaveFileUtils_DeleteSaveFile(PolytopiaBackendBase.Game.GameType gameType, string gameId, bool localOnly)
+ {
+ DeleteStateChecksum(gameId);
+ }
+
+ ///
+ /// Deletes the stored checksums of all saved games of a type when they are deleted.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(LocalSaveFileUtils), nameof(LocalSaveFileUtils.DeleteAllSaveFilesOfType))]
+ private static void LocalSaveFileUtils_DeleteAllSaveFilesOfType(PolytopiaBackendBase.Game.GameType gameType, bool localOnly)
+ {
+ foreach (var gameId in LocalSaveFileUtils.GetSaveFiles(gameType))
+ {
+ DeleteStateChecksum(gameId);
+ }
+ }
+
+ ///
+ /// Deletes the stored checksum of a game when the match ends.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(GameManager), nameof(GameManager.MatchEnded))]
+ private static void GameManager_MatchEnded(bool localPlayerIsWinner, ScoreDetails scoreDetails, byte winnerId)
+ {
+ DeleteStateChecksum(GameManager.Client.gameId);
+ }
+
+ ///
+ /// Stores the current mod checksum when a new game session is created.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(ClientBase), nameof(ClientBase.CreateSession), typeof(GameSettings), typeof(Il2CppSystem.Guid))]
+ private static void ClientBase_CreateSession(GameSettings settings, Il2CppSystem.Guid gameId)
+ {
+ SaveStateChecksum(gameId);
+ }
+
+ ///
+ /// Resets game settings if necessary when the tribe selector screen is shown.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(TribeSelectorScreen), nameof(TribeSelectorScreen.Show))]
+ private static bool TribeSelectorScreen_Show(bool instant = false)
+ {
+ if (shouldResetSettings)
+ {
+ RestorePreliminaryGameSettings();
+ shouldResetSettings = false;
+ }
+ return true;
+ }
+
+ ///
+ /// Restores the preliminary game settings to their default values.
+ ///
+ internal static void RestorePreliminaryGameSettings()
+ {
+ GameManager.PreliminaryGameSettings.disabledTribes.Clear();
+ GameManager.PreliminaryGameSettings.selectedSkins.Clear();
+ GameManager.PreliminaryGameSettings.SaveToDisk();
+ }
+
+ ///
+ /// Initializes the Compatibility manager by patching the necessary methods.
+ ///
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Compatibility));
+ }
}
diff --git a/src/Managers/Hub.cs b/src/Managers/Hub.cs
index 5afdaf9..e28648e 100644
--- a/src/Managers/Hub.cs
+++ b/src/Managers/Hub.cs
@@ -1,443 +1,413 @@
-using System.Text.Json;
+using System.Globalization;
using Cpp2IL.Core.Extensions;
using HarmonyLib;
-using I2.Loc;
using Il2CppInterop.Runtime;
-using TMPro;
using UnityEngine;
-using UnityEngine.EventSystems;
-using UnityEngine.UI;
using static PopupBase;
-using PolytopiaBackendBase.Common;
-using System.Text.Encodings.Web;
-using Il2CppSystem.Linq;
-using System.Text.RegularExpressions;
-
namespace PolyMod.Managers;
-///
-/// Manages the PolyMod hub, including UI elements and popups.
-///
internal static class Hub
{
- private const string HEADER_PREFIX = "";
- private const string HEADER_POSTFIX = "";
- private const int POPUP_WIDTH = 1400;
- private static UIRoundButton_UI2? polyModButton = null;
- private static UIRoundButton_UI2? polyModVersion = null;
-
- ///
- /// Whether the configuration popup is currently active.
- ///
- public static bool isConfigPopupActive = false;
-
- ///
- /// Patches the splash screen to play a custom intro video.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))]
- private static bool SplashController_LoadAndPlayClip(SplashController __instance)
- {
- string name = "intro.mp4";
- string path = Path.Combine(Application.persistentDataPath, name);
- File.WriteAllBytesAsync(path, Plugin.GetResource(name).ReadBytes());
- __instance.lastPlayTime = Time.realtimeSinceStartup;
- __instance.videoPlayer.url = path;
- __instance.videoPlayer.Play();
- return false;
- }
-
- ///
- /// Patches the popup button container to correctly anchor buttons.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(PopupButtonContainer), nameof(PopupButtonContainer.SetButtonData))]
- private static void PopupButtonContainer_SetButtonData(PopupButtonContainer __instance)
- {
- int num = __instance.buttons.Length;
- for (int i = 0; i < num; i++)
- {
- UITextButton uitextButton = __instance.buttons[i];
- Vector2 vector = new((num == 1) ? 0.5f : (i / (num - 1.0f)), 0.5f);
- uitextButton.rectTransform.anchorMin = vector;
- uitextButton.rectTransform.anchorMax = vector;
- uitextButton.rectTransform.pivot = vector;
- }
- }
-
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))]
- private static void StartScreen_UI2_Init(StartScreen_UI2 __instance, RectTransform transform)
- {
- polyModButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
- polyModButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("polymod_icon.png").ReadBytes());
- polyModButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(ShowPolyModHub));
-
- polyModVersion = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
- polyModVersion.Text = $"PolyMod {Plugin.VERSION}";
- polyModVersion.titleTextField.textField.fontSize = 18f;
-
- if (Main.dependencyCycle)
- {
- BasicPopup popup = PopupManager.GetBasicPopupWithData(
- new(
- Localization.Get("polymod.cycle"),
- Localization.Get("polymod.cycle.description"),
- new PopupBase.PopupButtonData[] {
- new(
- "buttons.exitgame",
- PopupBase.PopupButtonData.States.None,
- (Il2CppSystem.Action)Application.Quit,
- closesPopup: false,
- customColorStates: ColorConstants.redButtonColorStates
- )
- }
- )
- );
-
- popup.IsUnskippable = true;
- popup.Show();
- }
-
- }
-
- ///
- /// Patches the start screen to add the PolyMod hub button and version text.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))]
- private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize)
- {
- if (polyModButton == null)
- {
- Plugin.logger.LogWarning("PolyMod Hub button is null when running layout!");
- return;
- }
- polyModButton.iconContainer.gameObject.SetActive(false);
- polyModButton.outline.gameObject.SetActive(false);
- polyModButton.bg.color = Color.white;
- polyModButton.Text = Localization.Get("polymod.hub");
- float num = 50f;
- polyModButton.SetPosition(screenSize.safeRect.Right - (num * 2.5f), screenSize.safeRect.Top - num);
-
- if (polyModVersion == null)
- {
- Plugin.logger.LogWarning("PolyMod Version is null when running layout!");
- return;
- }
- polyModVersion.iconContainer.gameObject.SetActive(false);
- polyModVersion.outline.gameObject.SetActive(false);
- polyModVersion.bg.gameObject.SetActive(false);
- polyModVersion.SetPosition(screenSize.safeRect.Left + num * 1.15f, screenSize.safeRect.Bottom + num * 1.15f);
- }
-
- ///
- /// Patches the game manager to handle key presses for the config popup.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))]
- private static void GameManager_Update()
- {
- if (Input.GetKey(KeyCode.LeftControl) && Input.GetKeyDown(KeyCode.Tab) && !isConfigPopupActive)
- {
- ShowConfigPopup();
- }
- }
-
- ///
- /// Updates sprite information from dumped files.
- ///
- internal static void UpdateSpriteInfos()
- {
- string message = string.Empty;
- Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH);
-
- foreach (var file in Directory.GetFiles(Plugin.DUMPED_DATA_PATH))
- {
- string? name = Path.GetFileNameWithoutExtension(file);
- List subnames = new();
- if (name.Contains("sprites_"))
- {
- subnames = name.Split('_').ToList();
- Mod.File spriteInfo = new(Path.GetFileNameWithoutExtension(file), File.ReadAllBytes(file));
- Dictionary? deserialized = Loader.LoadSpriteInfoFile(Registry.mods[subnames[1]], spriteInfo);
- if (deserialized != null)
- {
- foreach (var kvp in deserialized)
- {
- Loader.UpdateSprite(kvp.Key);
- }
- message += Localization.Get("polymod.spriteinfo.updated", new Il2CppSystem.Object[] { subnames[1] });
- }
- }
- }
- if (message == string.Empty)
- {
- message = Localization.Get("polymod.spriteinfo.notupdated");
- }
- NotificationManager.Notify(message);
- }
-
- ///
- /// Dump all data.
- ///
- internal static void DumpData()
- {
- Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH);
- Directory.CreateDirectory(Plugin.LOGIC_DUMP_PATH);
- Directory.CreateDirectory(Plugin.LOCALIZATION_DUMP_PATH);
- File.WriteAllTextAsync(
- Path.Combine(Plugin.LOGIC_DUMP_PATH, "gameLogicData.json"),
- PolytopiaDataManager.provider.LoadGameLogicData(VersionManager.GameLogicDataVersion)
- );
- File.WriteAllTextAsync(
- Path.Combine(Plugin.LOGIC_DUMP_PATH, "avatarData.json"),
- PolytopiaDataManager.provider.LoadAvatarData(1337)
- );
- var source = LocalizationManager.Sources[0];
- foreach (var language in source.GetLanguages())
- {
- int languageIndex = source.GetLanguageIndex(language);
- var dict = new Dictionary();
-
- foreach (var term in source.mTerms)
- {
- var translation = term.GetTranslation(languageIndex);
-
- if (!string.IsNullOrEmpty(translation))
- dict[term.Term] = translation;
- }
- var options = new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- };
-
- string json = JsonSerializer.Serialize(dict, options);
-
- File.WriteAllText(
- Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"language_{source.mLanguages[languageIndex].Code}.json"),
- json
- );
- }
- foreach (var category in source.GetCategories())
- File.WriteAllTextAsync(
- Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"localization_{category}.csv"),
- source.Export_CSV(category)
- );
- foreach (KeyValuePair entry in Registry.mods)
- {
- foreach (Mod.File file in entry.Value.files)
- {
- Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$");
- if (spritesMatch.Success)
- File.WriteAllBytes(
- Path.Combine(
- Plugin.DUMPED_DATA_PATH,
- $"{Path.GetFileNameWithoutExtension(file.name)}_{entry.Key}.json"
- ),
- file.bytes
- );
- }
- }
- foreach (TribeType type in Enum.GetValues(typeof(TribeType)))
- {
- List previewTiles = new();
- SelectTribePopup popup = PopupManager.GetSelectTribePopup();
- for (int x = -3; x <= 3; x++)
- {
- for (int y = -7; y <= 7; y++)
- {
- Vector2Int pos = new Vector2Int(x, y);
- if (popup.UIWorldPreview.worldPreviewData.TryGetData(pos, type, out UITileData tileData))
- {
- Visual.PreviewTile previewTile = new Visual.PreviewTile
- {
- x = tileData.Position.x,
- y = tileData.Position.y,
- terrainType = tileData.terrainType,
- resourceType = tileData.resourceType,
- unitType = tileData.unitType,
- improvementType = tileData.improvementType
- };
- previewTiles.Add(previewTile);
- }
- }
- }
- File.WriteAllTextAsync(
- Path.Combine(Plugin.DUMPED_DATA_PATH, $"preview_{type}.json"),
- JsonSerializer.Serialize(previewTiles, new JsonSerializerOptions { WriteIndented = true })
- );
- }
- NotificationManager.Notify(Localization.Get("polymod.hub.dumped"));
- }
-
- ///
- /// Shows the configuration popup.
- ///
- internal static void ShowConfigPopup()
- {
- BasicPopupLegacy polymodPopup = Visual.GetBasicPopupLegacy();
- polymodPopup.Header = Localization.Get("polymod.hub.config");
- polymodPopup.Description = "";
- polymodPopup.buttonData = CreateConfigPopupButtonData();
- polymodPopup.ShowSetWidth(POPUP_WIDTH);
- }
-
- internal static void ShowPolyModHub()
- {
- BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
- popup.Header = Localization.Get("polymod.hub");
- popup.Description = Localization.Get("polymod.hub.header", new Il2CppSystem.Object[] {
- HEADER_PREFIX,
- HEADER_POSTFIX
- }) + "\n\n";
- foreach (var mod in Registry.mods.Values)
- {
- popup.Description += Localization.Get("polymod.hub.mod", new Il2CppSystem.Object[] {
- mod.name,
- Localization.Get("polymod.hub.mod.status."
- + Enum.GetName(typeof(Mod.Status), mod.status)!.ToLower()),
- string.Join(", ", mod.authors),
- mod.version.ToString(),
- mod.description ?? ""
- });
- popup.Description += "\n\n";
- }
- popup.Description += Localization.Get("polymod.hub.footer", new Il2CppSystem.Object[] {
- HEADER_PREFIX,
- HEADER_POSTFIX
- });
-
- void OpenDiscord()
- {
- NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false);
- }
-
- List popupButtons = new()
- {
- new("buttons.back"),
- new(
- "polymod.hub.discord",
- callback: DelegateSupport.ConvertDelegate(OpenDiscord)
- ),
- new(
- "polymod.hub.config",
- callback: DelegateSupport.ConvertDelegate(ShowConfigPopup)
- )
- };
- if (Plugin.config.debug)
- {
- popupButtons.Add(new(
- "polymod.hub.dump",
- callback: DelegateSupport.ConvertDelegate(DumpData),
- closesPopup: false
- ));
- popupButtons.Add(new(
- "polymod.hub.spriteinfo.update",
- callback: DelegateSupport.ConvertDelegate(UpdateSpriteInfos),
- closesPopup: false
- ));
- }
- popup.buttonData = popupButtons.ToArray();
- popup.ShowSetWidth(POPUP_WIDTH);
- }
-
- ///
- /// Creates the button data for the configuration popup.
- ///
- /// An array of popup button data.
- internal static PopupButtonData[] CreateConfigPopupButtonData()
- {
- List popupButtons = new()
- {
- new(Localization.Get("buttons.back"), PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnBackButtonClicked), -1, true, null)
- };
-
- if (GameManager.Instance.isLevelLoaded)
- {
- popupButtons.Add(new PopupButtonData(Localization.Get("polymod.hub.spriteinfo.update"), PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnUpdateSpritesButtonClicked), -1, true, null));
- }
- else
- {
- string debugButtonName = Localization.Get(
- Plugin.config.debug ? "polymod.hub.config.disable" : "polymod.hub.config.enable",
- new Il2CppSystem.Object[] { Localization.Get("polymod.debug",
- new Il2CppSystem.Object[]{}).ToUpperInvariant() }
- );
- string autoUpdateButtonName = Localization.Get(
- Plugin.config.autoUpdate ? "polymod.hub.config.disable" : "polymod.hub.config.enable",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate",
- new Il2CppSystem.Object[]{}).ToUpperInvariant() }
- );
- string includeAlphasButtonName = Localization.Get(
- Plugin.config.updatePrerelease ? "polymod.hub.config.disable" : "polymod.hub.config.enable",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate.alpha",
- new Il2CppSystem.Object[]{}).ToUpperInvariant() }
- );
- popupButtons.Add(new PopupButtonData(debugButtonName, PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnDebugButtonClicked), -1, true, null));
- popupButtons.Add(new PopupButtonData(autoUpdateButtonName, PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnAutoUpdateButtonClicked), -1, true, null));
- popupButtons.Add(new PopupButtonData(includeAlphasButtonName, Plugin.config.autoUpdate ? PopupButtonData.States.None : PopupButtonData.States.Disabled, DelegateSupport.ConvertDelegate(OnIncludeAlphasButtonClicked), -1, true, null));
- }
- return popupButtons.ToArray();
-
- void OnDebugButtonClicked()
- {
- Plugin.config = new(debug: !Plugin.config.debug, autoUpdate: Plugin.config.autoUpdate, updatePrerelease: Plugin.config.updatePrerelease);
- Plugin.WriteConfig();
- Plugin.UpdateConsole();
- NotificationManager.Notify(Localization.Get(
- "polymod.config.setto",
- new Il2CppSystem.Object[] { Localization.Get("polymod.debug",
- new Il2CppSystem.Object[]{}), Plugin.config.debug }
- ));
- isConfigPopupActive = false;
- }
-
- void OnAutoUpdateButtonClicked()
- {
- Plugin.config = new(debug: Plugin.config.debug, autoUpdate: !Plugin.config.autoUpdate, updatePrerelease: Plugin.config.updatePrerelease);
- Plugin.WriteConfig();
- Plugin.UpdateConsole();
- NotificationManager.Notify(Localization.Get(
- "polymod.config.setto",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate",
- new Il2CppSystem.Object[]{}), Plugin.config.autoUpdate }
- ));
- isConfigPopupActive = false;
- }
-
- void OnIncludeAlphasButtonClicked()
- {
- Plugin.config = new(debug: Plugin.config.debug, autoUpdate: Plugin.config.autoUpdate, updatePrerelease: !Plugin.config.updatePrerelease);
- Plugin.WriteConfig();
- Plugin.UpdateConsole();
- NotificationManager.Notify(Localization.Get(
- "polymod.config.setto",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate.alpha",
- new Il2CppSystem.Object[]{}), Plugin.config.updatePrerelease }
- ));
- isConfigPopupActive = false;
- }
-
- void OnUpdateSpritesButtonClicked()
- {
- UpdateSpriteInfos();
- isConfigPopupActive = false;
- }
-
- void OnBackButtonClicked()
- {
- isConfigPopupActive = false;
- }
- }
-
- ///
- /// Initializes the Hub manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(Hub));
- }
+ private record Setting(
+ string buttonKey,
+ string nameKey,
+ string descriptionKey,
+ Func isEnabled,
+ Action toggle
+ );
+
+ private const string HEADER_PREFIX = "";
+ private const string HEADER_POSTFIX = "";
+
+ private const string COLOR_MUTED = "#9AA0A6";
+ private const string COLOR_ENABLED = "#4CC76A";
+ private const string COLOR_DISABLED = "#E36A5B";
+ private const string COLOR_WARNING = "#E0A52E";
+ private const int CHECKSUM_LENGTH = 12;
+ private const float HUB_POPUP_WIDTH_SHARE = 0.78f;
+ private const float HUB_POPUP_MAX_WIDTH = 1400f;
+ private const float SETTINGS_POPUP_WIDTH_SHARE = 0.5f;
+ private const float SETTINGS_POPUP_MAX_WIDTH = 900f;
+ private const float START_SCREEN_MARGIN = 50f;
+ private const float VERSION_LABEL_FONT_SIZE = 18f;
+ private const float VERSION_LABEL_GAP = 10f;
+ /// Margin the game keeps between the safe area and its own corner widgets.
+ private const float VERSION_LABEL_MARGIN = 15f;
+ private const string INTRO = "intro.mp4";
+
+ private const KeyCode HOTKEY_MODIFIER = KeyCode.LeftControl;
+ private const KeyCode HOTKEY_MODIFIER_ALTERNATIVE = KeyCode.RightControl;
+ private const KeyCode HOTKEY = KeyCode.Tab;
+
+ private static readonly Setting[] SETTINGS = new Setting[]
+ {
+ new(
+ "polymod.hub.config.debug",
+ "polymod.debug",
+ "polymod.debug.description",
+ () => Plugin.config.debug,
+ () =>
+ {
+ Plugin.config = Plugin.config with { debug = !Plugin.config.debug };
+ Plugin.UpdateConsole();
+ }
+ ),
+ new(
+ "polymod.hub.config.coordinates",
+ "polymod.coordinates",
+ "polymod.coordinates.description",
+ () => Plugin.config.showCoordinates,
+ () => Plugin.config = Plugin.config with { showCoordinates = !Plugin.config.showCoordinates }
+ ),
+ };
+
+ private static UIRoundButton_UI2? polyModButton = null;
+ private static NotificationBadge? brokenModsBadge = null;
+ private static TextField_UI2? polyModVersion = null;
+
+ private static PopupBase? openPopup = null;
+
+ public static bool IsPopupOpen
+ {
+ get
+ {
+ if (openPopup != null)
+ {
+ try
+ {
+ if (openPopup.IsShowing()) return true;
+ }
+ catch (Exception) { }
+ openPopup = null;
+ }
+ return false;
+ }
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))]
+ private static bool SplashController_LoadAndPlayClip(SplashController __instance)
+ {
+ string path = Path.Combine(Application.persistentDataPath, INTRO);
+ File.WriteAllBytesAsync(path, Plugin.GetResource(INTRO).ReadBytes());
+ __instance.lastPlayTime = Time.realtimeSinceStartup;
+ __instance.videoPlayer.url = path;
+ __instance.videoPlayer.Play();
+ return false;
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))]
+ private static void StartScreen_UI2_Init(RectTransform transform)
+ {
+ polyModButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
+ polyModButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("polymod_icon.png").ReadBytes());
+ polyModButton.OnClickedSignal.Add(Callback(ShowPolyModHub));
+
+ brokenModsBadge = UILibrary.NewNotificationBadge(polyModButton.rectTransform).SetValue(CountBrokenMods());
+ polyModButton.SetOverlay(brokenModsBadge, UIButtonBase_UI2.OverlayPosition.TopRight);
+
+ // NewHeaderTinyText is the prefab a round button builds its own title from, so the label
+ // keeps the game's header font rather than the lighter body font NewText would give it.
+ polyModVersion = UILibrary.NewHeaderTinyText(transform);
+ polyModVersion.gameObject.name = "PolyModVersion";
+ polyModVersion.textField.fontSize = VERSION_LABEL_FONT_SIZE;
+ polyModVersion.textField.color = Color.white;
+ polyModVersion.Text = $"PolyMod {Plugin.VERSION}";
+
+ if (Main.dependencyCycle)
+ ShowDependencyCyclePopup();
+ }
+
+ ///
+ /// Patches the start screen layout to place the PolyMod hub button and the version label.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))]
+ private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize)
+ {
+ if (polyModButton == null || polyModVersion == null)
+ {
+ Plugin.logger.LogWarning("PolyMod start screen widgets are missing, skipping their layout.");
+ return;
+ }
+
+ polyModButton.iconContainer.gameObject.SetActive(false);
+ polyModButton.outline.gameObject.SetActive(false);
+ polyModButton.bg.color = Color.white;
+ // Setting the text re-runs the button's own layout, which also repositions the badge.
+ polyModButton.Text = Localization.Get("polymod.hub");
+ polyModButton.SetPosition(
+ screenSize.safeRect.Right - START_SCREEN_MARGIN * 2.5f,
+ screenSize.safeRect.Top - START_SCREEN_MARGIN
+ );
+ brokenModsBadge?.SetValue(CountBrokenMods());
+
+ // Anchored by its edges, not its centre, so a longer version string cannot run off screen.
+ float versionX = screenSize.safeRect.Left + VERSION_LABEL_MARGIN + polyModVersion.GetHalfWidth();
+ float versionY = screenSize.safeRect.Bottom + VERSION_LABEL_MARGIN + polyModVersion.GetHalfHeight();
+ polyModVersion.SetPosition(versionX, versionY);
+
+ // The bottom row of buttons is centred and of fixed width, so a narrow window walks it into
+ // the version label. The row is already laid out by now, so lift the label clear of it.
+ UIRoundButton_UI2 bottomRowStart = __instance.settingsButton;
+ if (bottomRowStart != null
+ && polyModVersion.GetRight() + VERSION_LABEL_GAP > bottomRowStart.GetLeft())
+ {
+ polyModVersion.SetPosition(
+ versionX,
+ bottomRowStart.GetTop() + polyModVersion.GetHalfHeight() + VERSION_LABEL_GAP
+ );
+ }
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))]
+ private static void GameManager_Update()
+ {
+ bool modifierHeld = Input.GetKey(HOTKEY_MODIFIER) || Input.GetKey(HOTKEY_MODIFIER_ALTERNATIVE);
+ if (modifierHeld && Input.GetKeyDown(HOTKEY) && !IsPopupOpen)
+ ShowPolyModHub();
+ }
+
+ internal static void ShowPolyModHub()
+ {
+ BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
+ popup.Header = Localization.Get("polymod.hub");
+ popup.Description = string.Join(
+ "\n\n",
+ Localization.Get("polymod.hub.header", Args(HEADER_PREFIX, HEADER_POSTFIX)),
+ BuildModList(),
+ Localization.Get("polymod.hub.footer", Args(HEADER_PREFIX, HEADER_POSTFIX))
+ );
+
+ void OpenDiscord() => NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false);
+
+ List buttons = new()
+ {
+ new("buttons.back"),
+ new("polymod.hub.discord", callback: Callback(OpenDiscord)),
+ new("polymod.hub.config", callback: Callback(ShowConfigPopup)),
+ };
+ if (Plugin.config.debug)
+ {
+ buttons.Add(new("polymod.hub.dump", callback: Callback(DumpData), closesPopup: false));
+ buttons.Add(new("polymod.hub.hotreload", callback: Callback(HotReload), closesPopup: false));
+ }
+
+ popup.buttonData = buttons.ToArray();
+ openPopup = popup;
+ popup.ShowSetWidth(HUB_POPUP_WIDTH_SHARE, HUB_POPUP_MAX_WIDTH);
+ }
+
+ internal static void ShowConfigPopup()
+ {
+ BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
+ popup.Header = Localization.Get("polymod.hub.config");
+ popup.Description = BuildSettingsList();
+
+ List buttons = new()
+ {
+ new("buttons.back", callback: Callback(ShowPolyModHub)),
+ };
+ foreach (Setting setting in SETTINGS)
+ {
+ void OnSettingClicked()
+ {
+ setting.toggle();
+ Plugin.WriteConfig();
+ popup.Description = BuildSettingsList();
+ }
+ buttons.Add(new(setting.buttonKey, callback: Callback(OnSettingClicked), closesPopup: false));
+ }
+
+ popup.buttonData = buttons.ToArray();
+ openPopup = popup;
+ popup.ShowSetWidth(SETTINGS_POPUP_WIDTH_SHARE, SETTINGS_POPUP_MAX_WIDTH);
+ }
+
+ private static void ShowDependencyCyclePopup()
+ {
+ BasicPopup popup = PopupManager.GetBasicPopupWithData(
+ new(
+ Localization.Get("polymod.cycle"),
+ Localization.Get("polymod.cycle.description"),
+ new PopupButtonData[]
+ {
+ new(
+ "buttons.exitgame",
+ PopupButtonData.States.None,
+ (Il2CppSystem.Action)Application.Quit,
+ closesPopup: false,
+ customColorStates: ColorConstants.redButtonColorStates
+ ),
+ }
+ )
+ );
+ popup.IsUnskippable = true;
+ popup.Show();
+ }
+
+ private static void DumpData()
+ {
+ Dumper.DumpAll();
+ string path = Dumper.RootPath;
+
+ BasicPopup popup = PopupManager.GetBasicPopup();
+ popup.Header = Localization.Get("polymod.hub.dump");
+ popup.Description = Localization.Get("polymod.hub.dumped", Args(path));
+
+ void OpenDumpFolder()
+ {
+ try
+ {
+ Application.OpenURL(new Uri(path).AbsoluteUri);
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Could not open the dump folder at {path}: {exception}");
+ }
+ }
+
+ popup.buttonData = new PopupButtonData[]
+ {
+ new("polymod.hub.openfolder", callback: Callback(OpenDumpFolder)),
+ new("buttons.ok"),
+ };
+ popup.Show();
+ }
+
+ private static void HotReload()
+ {
+ BasicPopup popup = PopupManager.GetBasicPopup();
+ popup.Header = Localization.Get("polymod.hub.hotreload");
+ popup.Description = Localization.Get("polymod.hotreload.unavailable");
+ popup.SetDefaultOkbutton();
+ popup.Show();
+ }
+
+ private static string BuildModList()
+ {
+ if (Registry.mods.Count == 0)
+ return Muted(Localization.Get("polymod.hub.mods.none"));
+
+ int loaded = 0;
+ foreach (Mod mod in Registry.mods.Values)
+ {
+ if (mod.status == Mod.Status.Success) loaded++;
+ }
+
+ string summary = Localization.Get(
+ "polymod.hub.summary",
+ Args(loaded.ToString(CultureInfo.InvariantCulture), Registry.mods.Count.ToString(CultureInfo.InvariantCulture))
+ );
+ summary += " " + Localization.Get(
+ "polymod.hub.checksum",
+ Args(ShortChecksum(Compatibility.checksum))
+ );
+
+ List blocks = new() { Muted(summary) };
+ foreach (Mod mod in Registry.mods.Values)
+ {
+ blocks.Add(BuildModEntry(mod));
+ }
+ return string.Join("\n\n", blocks);
+ }
+
+ ///
+ /// Shortens a checksum to the leading characters shown in the hub.
+ ///
+ /// The full checksum.
+ /// The shortened checksum.
+ private static string ShortChecksum(string checksum)
+ {
+ if (string.IsNullOrEmpty(checksum)) return "-";
+
+ return checksum.Length <= CHECKSUM_LENGTH ? checksum : checksum.Substring(0, CHECKSUM_LENGTH);
+ }
+
+ private static string BuildModEntry(Mod mod)
+ {
+ string status = Localization.Get(
+ "polymod.hub.mod.status." + Enum.GetName(mod.status)!.ToLowerInvariant()
+ );
+ string title = $"{mod.name} " + Muted($"v{mod.version}");
+ string meta = $"{status}";
+ if (mod.authors.Length > 0)
+ {
+ string authors = Localization.Get(
+ "polymod.hub.mod.authors",
+ Args(string.Join(", ", mod.authors))
+ );
+ meta += " " + Muted($"{authors}");
+ }
+ if (!string.IsNullOrEmpty(mod.checksum))
+ {
+ string modChecksum = Localization.Get(
+ "polymod.hub.mod.checksum",
+ Args(ShortChecksum(mod.checksum))
+ );
+ meta += " " + Muted($"{modChecksum}");
+ }
+
+ List lines = new() { title, meta };
+ if (!string.IsNullOrWhiteSpace(mod.description))
+ lines.Add($"{mod.description}");
+
+ return string.Join("\n", lines);
+ }
+
+ private static string BuildSettingsList()
+ {
+ List blocks = new();
+ foreach (Setting setting in SETTINGS)
+ {
+ bool enabled = setting.isEnabled();
+ string state = Localization.Get(enabled ? "polymod.on" : "polymod.off");
+ string color = enabled ? COLOR_ENABLED : COLOR_DISABLED;
+ blocks.Add(
+ $"{Localization.Get(setting.nameKey)} {state}\n"
+ + Muted($"{Localization.Get(setting.descriptionKey)}")
+ );
+ }
+ return string.Join("\n\n", blocks);
+ }
+
+ private static string Muted(string text) => $"{text}";
+
+ private static string StatusColor(Mod.Status status) => status switch
+ {
+ Mod.Status.Success => COLOR_ENABLED,
+ Mod.Status.DependenciesUnsatisfied => COLOR_WARNING,
+ _ => COLOR_DISABLED,
+ };
+
+ private static int CountBrokenMods()
+ {
+ int broken = 0;
+ foreach (Mod mod in Registry.mods.Values)
+ {
+ if (mod.status != Mod.Status.Success) broken++;
+ }
+ return broken;
+ }
+
+ private static Il2CppSystem.Action Callback(Action callback)
+ => DelegateSupport.ConvertDelegate(callback)!;
+
+ private static Il2CppSystem.Object[] Args(params string[] values)
+ {
+ Il2CppSystem.Object[] arguments = new Il2CppSystem.Object[values.Length];
+ for (int i = 0; i < values.Length; i++)
+ {
+ arguments[i] = values[i];
+ }
+ return arguments;
+ }
+
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Hub));
+ }
}
diff --git a/src/Managers/Loc.cs b/src/Managers/Loc.cs
index b3b7d17..239b4be 100644
--- a/src/Managers/Loc.cs
+++ b/src/Managers/Loc.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using HarmonyLib;
using I2.Loc;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
@@ -13,163 +14,163 @@ namespace PolyMod.Managers;
///
public static class Loc
{
- internal static Dictionary> languagesToAdd = new();
- internal static Dictionary buildingsInfoOverrides = new();
-
- ///
- /// Patches the localization getter to handle custom enum values.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(Localization), nameof(Localization.Get), typeof(string), typeof(Il2CppReferenceArray))]
- private static bool Localization_Get(ref string key, Il2CppReferenceArray args)
- {
- List keys = key.Split('.').ToList();
- int? idx = null;
- string? name = null;
-
- // Find any custom enum indices in the localization key
- foreach (string item in keys)
- {
- if (int.TryParse(item, out int parsedIdx))
- {
- if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM)
- {
- idx = parsedIdx;
- }
- }
- }
-
- // If a custom enum index is found, try to resolve its name
- if (idx != null)
- {
- foreach (var typeMapping in Loader.typeMappings.Values)
- {
- if (!typeMapping.shouldCreateCache)
- continue;
-
- MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(typeMapping.type).GetMethod("TryGetName");
- if (methodInfo != null)
- {
- object?[] parameters = { idx, null };
- object? methodInvokeResult = methodInfo.Invoke(null, parameters);
- if (methodInvokeResult != null)
- {
- if ((bool)methodInvokeResult)
- {
- name = (string?)parameters[1];
- }
- }
- }
- }
-
- // If the name was resolved, replace the index with the name in the key
- if (name != null && idx != null)
- {
- int index = keys.IndexOf(idx.ToString()!);
- keys[index] = name;
- key = string.Join(".", keys);
- }
- }
- return true;
- }
-
- [HarmonyPrefix]
- [HarmonyPatch(typeof(Localization), nameof(Localization.Init))]
- private static bool Localization_Init()
- {
- if (Localization.initialized)
- return true;
-
- if (LocalizationManager.Sources.Count == 0)
- LocalizationManager.UpdateSources();
-
- LanguageSourceData source = LocalizationManager.Sources[0];
-
- // Elyrion has no language code. I am almost completely sure it is a bug, by looking at the code.
- LanguageData elyrionLanguage = source.GetLanguageData("Elyrion");
- elyrionLanguage.Code = Localization.LANG_CODE_ELYRION;
-
- foreach (var languageCode in languagesToAdd.Keys)
- {
- Dictionary terms = languagesToAdd[languageCode];
-
- int languageIndex = source.GetLanguageIndexFromCode(languageCode);
- string languageName = terms["language"];
- if (languageIndex == -1)
- {
- source.AddLanguage(languageName, languageCode);
- languageIndex = source.GetLanguageIndex(languageName);
- }
-
- foreach (var kvp in terms)
- {
- TermData term = source.GetTermData(kvp.Key);
- if (term == null)
- {
- source.AddTerm(kvp.Key);
- term = source.GetTermData(kvp.Key);
- }
-
- term.Languages[languageIndex] = kvp.Value;
- }
-
- LocalizationManager.UpdateSources();
-
- Plugin.logger.LogInfo($"{languageCode} language terms added.");
- }
- return true;
- }
-
- [HarmonyPrefix]
- [HarmonyPatch(typeof(BuildingUtils), nameof(BuildingUtils.GetInfo))]
- private static bool BuildingUtils_GetInfo(ref string __result, SkinType skinOfCurrentLocalPlayer,
- ImprovementData improvementData, ImprovementState improvementState, PlayerState owner, TileData tileData)
- {
- TribeType tribe = TribeType.None;
- if (owner != null)
- tribe = owner.tribe;
-
- string key = EnumCache.GetName(improvementData.type);
- if (buildingsInfoOverrides.ContainsKey(key))
- {
- __result = Localization.GetSkinned(skinOfCurrentLocalPlayer, tribe, buildingsInfoOverrides[key]);
- return false;
- }
-
- return true;
- }
-
- ///
- /// Builds and loads localization data from a dictionary.
- ///
- /// A dictionary containing the localization data.
- internal static void BuildAndLoadLocalization(Dictionary> localization)
- {
- foreach (var (key, data) in localization)
- {
- string name = ReplaceDashesWithDots(key);
- if (name.StartsWith("tribeskins")) name = "TribeSkins/" + name;
- TermData term = LocalizationManager.Sources[0].AddTerm(name);
- List strings = new();
- foreach (string language in LocalizationManager.GetAllLanguages(false))
- {
- strings.Add(data.GetOrDefault(language, data.GetOrDefault("English", term.Term))!);
- }
- term.Languages = new Il2CppStringArray(strings.ToArray());
- }
- }
-
- internal static string ReplaceDashesWithDots(string key)
- {
- string name = key.Replace("_", ".");
- return name.Replace("..", "_");
- }
-
- ///
- /// Initializes the Loc manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(Loc));
- }
+ internal static Dictionary> languagesToAdd = new();
+ internal static Dictionary buildingsInfoOverrides = new();
+
+ ///
+ /// Patches the localization getter to handle custom enum values.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(Localization), nameof(Localization.Get), typeof(string), typeof(Il2CppReferenceArray))]
+ private static bool Localization_Get(ref string key, Il2CppReferenceArray args)
+ {
+ List keys = key.Split('.').ToList();
+ int? idx = null;
+ string? name = null;
+
+ // Find any custom enum indices in the localization key
+ foreach (string item in keys)
+ {
+ if (int.TryParse(item, out int parsedIdx))
+ {
+ if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM)
+ {
+ idx = parsedIdx;
+ }
+ }
+ }
+
+ // If a custom enum index is found, try to resolve its name
+ if (idx != null)
+ {
+ foreach (var typeMapping in Loader.typeMappings.Values)
+ {
+ if (!typeMapping.shouldCreateCache)
+ continue;
+
+ MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(typeMapping.type).GetMethod("TryGetName");
+ if (methodInfo != null)
+ {
+ object?[] parameters = { idx, null };
+ object? methodInvokeResult = methodInfo.Invoke(null, parameters);
+ if (methodInvokeResult != null)
+ {
+ if ((bool)methodInvokeResult)
+ {
+ name = (string?)parameters[1];
+ }
+ }
+ }
+ }
+
+ // If the name was resolved, replace the index with the name in the key
+ if (name != null && idx != null)
+ {
+ int index = keys.IndexOf(idx.Value.ToString(CultureInfo.InvariantCulture));
+ keys[index] = name;
+ key = string.Join(".", keys);
+ }
+ }
+ return true;
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(Localization), nameof(Localization.Init))]
+ private static bool Localization_Init()
+ {
+ if (Localization.initialized)
+ return true;
+
+ if (LocalizationManager.Sources.Count == 0)
+ LocalizationManager.UpdateSources();
+
+ LanguageSourceData source = LocalizationManager.Sources[0];
+
+ // Elyrion has no language code. I am almost completely sure it is a bug, by looking at the code.
+ LanguageData elyrionLanguage = source.GetLanguageData("Elyrion");
+ elyrionLanguage.Code = Localization.LANG_CODE_ELYRION;
+
+ foreach (var languageCode in languagesToAdd.Keys)
+ {
+ Dictionary terms = languagesToAdd[languageCode];
+
+ int languageIndex = source.GetLanguageIndexFromCode(languageCode);
+ string languageName = terms["language"];
+ if (languageIndex == -1)
+ {
+ source.AddLanguage(languageName, languageCode);
+ languageIndex = source.GetLanguageIndex(languageName);
+ }
+
+ foreach (var kvp in terms)
+ {
+ TermData term = source.GetTermData(kvp.Key);
+ if (term == null)
+ {
+ source.AddTerm(kvp.Key);
+ term = source.GetTermData(kvp.Key);
+ }
+
+ term.Languages[languageIndex] = kvp.Value;
+ }
+
+ LocalizationManager.UpdateSources();
+
+ Plugin.logger.LogInfo($"{languageCode} language terms added.");
+ }
+ return true;
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(BuildingUtils), nameof(BuildingUtils.GetInfo))]
+ private static bool BuildingUtils_GetInfo(ref string __result, SkinType skinOfCurrentLocalPlayer,
+ ImprovementData improvementData, ImprovementState improvementState, PlayerState owner, TileData tileData)
+ {
+ TribeType tribe = TribeType.None;
+ if (owner != null)
+ tribe = owner.tribe;
+
+ string key = EnumCache.GetName(improvementData.type);
+ if (buildingsInfoOverrides.TryGetValue(key, out string? buildingInfoOverride))
+ {
+ __result = Localization.GetSkinned(skinOfCurrentLocalPlayer, tribe, buildingInfoOverride);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Builds and loads localization data from a dictionary.
+ ///
+ /// A dictionary containing the localization data.
+ internal static void BuildAndLoadLocalization(Dictionary> localization)
+ {
+ foreach (var (key, data) in localization)
+ {
+ string name = ReplaceDashesWithDots(key);
+ if (name.StartsWith("tribeskins", StringComparison.Ordinal)) name = "TribeSkins/" + name;
+ TermData term = LocalizationManager.Sources[0].AddTerm(name);
+ List strings = new();
+ foreach (string language in LocalizationManager.GetAllLanguages(false))
+ {
+ strings.Add(data.GetOrDefault(language, data.GetOrDefault("English", term.Term))!);
+ }
+ term.Languages = new Il2CppStringArray(strings.ToArray());
+ }
+ }
+
+ internal static string ReplaceDashesWithDots(string key)
+ {
+ string name = key.Replace("_", ".");
+ return name.Replace("..", "_");
+ }
+
+ ///
+ /// Initializes the Loc manager by patching the necessary methods.
+ ///
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Loc));
+ }
}
diff --git a/src/Managers/Main.cs b/src/Managers/Main.cs
index f271e77..a2483ad 100644
--- a/src/Managers/Main.cs
+++ b/src/Managers/Main.cs
@@ -17,432 +17,482 @@ namespace PolyMod.Managers;
///
public static class Main
{
- internal static bool dependencyCycle;
-
- ///
- /// The maximum tier for technology, used to extend the tech tree.
- ///
- internal const int MAX_TECH_TIER = 100;
-
- ///
- /// A stopwatch to measure the time taken to load mods.
- ///
- internal static readonly Stopwatch stopwatch = new();
-
- ///
- /// Whether the mod has been fully initialized.
- ///
- internal static bool fullyInitialized;
-
- ///
- /// A dictionary mapping unit IDs to the IDs of the units they embark into.
- ///
- internal static Dictionary embarkNames = new();
-
- ///