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 Unity main 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;
}
}