using System; using System.Collections.Generic; using System.IO; using MelonLoader; using Newtonsoft.Json; using UnityEngine; namespace ADOFAIRenderer.Localization; /// /// Provides access to localized strings and handles language synchronization with the game. /// /// Locale files are flat JSON objects ("key": "text") under Localization/Locale/, embedded in the mod /// assembly as resources; the file name without extension is the locale code (en.json -> "en"). /// The active locale follows the game language: LanguageSyncPatch calls after the game's /// RDString.Setup and RDString.ChangeLanguage. Only Korean maps to its own locale ("ko"); every other /// language uses English ("en"). /// /// /// Lookup order in : active locale, then English, then the key itself. /// Intended for the main Unity thread; the dictionaries are filled once during initialization and only read afterwards. /// /// internal static class LocalizationManager { /// Loaded locales: locale code (case-insensitive) -> key -> text. Keys are case-sensitive. private static readonly Dictionary> Locales = new(StringComparer.OrdinalIgnoreCase); /// Locale used when the language has no locale file and for keys missing in the active locale. private static readonly string FallbackLocaleCode = "en"; /// /// Event triggered when the active language changes. /// Raised by with notify: true when the language or the resolved locale differs from /// the current one; the argument is the new game language. Exceptions from handlers are caught and logged /// (an exception stops the remaining handlers of that invocation). /// public static event Action? OnLanguageChanged; /// /// Currently active SystemLanguage: the game language as passed to , also when it has no /// locale file of its own (e.g. Japanese, which displays English text). /// public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English; /// /// Currently active locale code (e.g. "en", "ko"): the locale whose strings returns first. /// Always a loaded locale or the fallback "en". /// public static string CurrentLocaleCode { get; private set; } = "en"; /// Set by the first call; later calls return immediately. private static bool _initialized; /// Initializes on first access to any member, so lookups work before RenderMod calls . static LocalizationManager() { Initialize(); } /// /// Initializes the localization system and loads embedded language dictionaries. /// Runs once; further calls do nothing. Selects the initial language without raising /// . /// public static void Initialize() { if (_initialized) return; _initialized = true; LoadEmbeddedLocales(); // Detect initial language from game settings. // At mod initialization RDString.Setup has usually not run yet, so this reads the field's initial value // (English); LanguageSyncPatch applies the real language when the game calls RDString.Setup. var initialLang = DetectGameLanguage(); SetLanguage(initialLang, notify: false); LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})"); } /// /// Logs through the MelonLoader logger of the mod, or to the console when that logger is not available /// (e.g. when this class is used outside the running mod). /// private static void LogMsg(string msg) { try { Melon.Logger.Msg(msg); } catch { Console.WriteLine(msg); } } /// Error counterpart of ; falls back to standard error. private static void LogError(string msg) { try { Melon.Logger.Error(msg); } catch { Console.Error.WriteLine(msg); } } /// /// Loads all *.json locale files embedded in this assembly. /// A resource is used when its name ends with ".json" and contains "Localization.Locale" (the manifest name built /// from the folder path, see the EmbeddedResource item in the .csproj). A file that fails to read or parse is /// logged and skipped; a later resource with the same locale code replaces an earlier one. /// private static void LoadEmbeddedLocales() { var assembly = typeof(LocalizationManager).Assembly; var resourceNames = assembly.GetManifestResourceNames(); foreach (var resourceName in resourceNames) { if (!resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue; if (!resourceName.Contains("Localization.Locale")) continue; try { using var stream = assembly.GetManifestResourceStream(resourceName); if (stream == null) continue; using var reader = new StreamReader(stream); var json = reader.ReadToEnd(); var dict = JsonConvert.DeserializeObject>(json); if (dict != null) { // Extract locale code from resource name: e.g. "ADOFAIRenderer.Localization.Locale.ko.json" -> "ko" var parts = resourceName.Split('.'); if (parts.Length >= 2) { var code = parts[^2].ToLowerInvariant(); Locales[code] = dict; LogMsg($"[Localization] Loaded locale '{code}' ({dict.Count} keys)."); } } } catch (Exception ex) { LogError($"[Localization] Failed to load locale resource '{resourceName}': {ex.Message}"); } } } /// /// Sets the active language and notifies subscribers if changed. /// The locale is chosen by ; when that locale is not loaded, "en" is used. /// /// Game language (as used by RDString.language). /// /// Whether to raise when the language or locale changes; false is used for /// the initial selection. /// public static void SetLanguage(SystemLanguage language, bool notify = true) { var localeCode = LanguageToLocaleCode(language); if (!Locales.ContainsKey(localeCode)) { // Fallback to English if translation for this language does not exist localeCode = FallbackLocaleCode; } var isChanged = language != CurrentLanguage || localeCode != CurrentLocaleCode; CurrentLanguage = language; CurrentLocaleCode = localeCode; if (isChanged && notify) { LogMsg($"[Localization] Language changed to: {CurrentLanguage} ({CurrentLocaleCode})"); try { OnLanguageChanged?.Invoke(CurrentLanguage); } catch (Exception ex) { LogError($"[Localization] Error during OnLanguageChanged notification: {ex.Message}"); } } } /// /// Retrieves a localized string for the specified key. /// Order: active locale, then English, then the key itself (so a missing key is visible in the UI). /// /// Localization key, e.g. "ui.render_config.window_title"; case-sensitive. /// The localized text, the key when no locale has it, or an empty string for a null/empty key. public static string Get(string key) { if (string.IsNullOrEmpty(key)) return string.Empty; if (!_initialized) Initialize(); // 1. Try currently active locale if (Locales.TryGetValue(CurrentLocaleCode, out var activeDict) && activeDict.TryGetValue(key, out var val)) { return val; } // 2. Fallback to English locale if (CurrentLocaleCode != FallbackLocaleCode && Locales.TryGetValue(FallbackLocaleCode, out var fallbackDict) && fallbackDict.TryGetValue(key, out var fallbackVal)) { return fallbackVal; } // 3. Fallback to raw key if missing return key; } /// /// Retrieves a formatted localized string. /// The text from is used as a template /// ({0}, {1}, ...), formatted with the current culture. When the template does not match the /// arguments (), the unformatted template is returned. /// /// Localization key. /// Format arguments; with none, the template is returned as is. public static string Get(string key, params object[] args) { var template = Get(key); if (args.Length == 0) return template; try { return string.Format(template, args); } catch (FormatException) { return template; } } /// /// Maps Unity's SystemLanguage enum to an internal locale code. /// Korean -> "ko"; every other language -> "en". A new locale file also needs an entry here. /// private static string LanguageToLocaleCode(SystemLanguage language) { return language switch { SystemLanguage.Korean => "ko", _ => "en" }; } /// /// Attempts to read the game's current language from RDString or Persistence. /// Falls back to English when both reads throw. /// private static SystemLanguage DetectGameLanguage() { try { // First attempt: RDString.language, a public static field of the game (initial value English, // assigned from Persistence.language in RDString.Setup) return RDString.language; } catch { try { // Fallback attempt: Persistence.language, the saved "language" preference (defaults to // Application.systemLanguage) return Persistence.language; } catch { return SystemLanguage.English; } } } } /// /// Convenient shorthand alias for . /// Every member forwards to the member of the same name in . /// // ReSharper disable once InconsistentNaming internal static class I18n { /// public static string Get(string key) => LocalizationManager.Get(key); /// public static string Get(string key, params object[] args) => LocalizationManager.Get(key, args); /// public static SystemLanguage CurrentLanguage => LocalizationManager.CurrentLanguage; /// public static string CurrentLocaleCode => LocalizationManager.CurrentLocaleCode; /// public static event Action? OnLanguageChanged { add => LocalizationManager.OnLanguageChanged += value; remove => LocalizationManager.OnLanguageChanged -= value; } }