using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using BepInEx; using BepInEx.Configuration; using Microsoft.CodeAnalysis; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")] [assembly: AssemblyCompany("GWYFTranslationUpdater")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0+a9d3046b810a7bd0dd1013e2f8e0683442b198ae")] [assembly: AssemblyProduct("GWYFTranslationUpdater")] [assembly: AssemblyTitle("GWYFTranslationUpdater")] [assembly: AssemblyVersion("1.0.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace GWYFTranslationUpdater { [BepInPlugin("codex.gwyf.translationupdater", "GWYF Translation Updater", "0.1.2")] public sealed class TranslationUpdaterPlugin : BaseUnityPlugin { private sealed class ManifestEntry { public string RelativePath { get; } public string Sha256 { get; } public long Size { get; } public ManifestEntry(string relativePath, string sha256, long size) { RelativePath = relativePath; Sha256 = sha256; Size = size; } } private ConfigEntry _enabled; private ConfigEntry _autoUpdateOnStartup; private ConfigEntry _startupDelaySeconds; private ConfigEntry _commandPollSeconds; private ConfigEntry _deleteMissingFiles; private ConfigEntry _translationRootPath; private string _translationRoot; private string _dataDirectory; private string _commandsPath; private string _statusPath; private string _manifestCachePath; private string _readmePath; private float _nextCommandPollAt; private float _startupCheckAt; private bool _startupUpdateTriggered; private DateTime _lastCommandWriteTimeUtc = DateTime.MinValue; private Task? _updateTask; private const string ManifestUrl = "https://raw.githubusercontent.com/XoF-eLtTiL/GWYF-zhtw-Translation/refs/heads/main/manifest.txt"; private const string RawBaseUrl = "https://raw.githubusercontent.com/XoF-eLtTiL/GWYF-zhtw-Translation/refs/heads/main/translations"; private const long MaxDownloadBytes = 26214400L; private static readonly string[] AllowedPathPrefixes = new string[2] { "zh-TW/Text/", "zh-TW/Texture/" }; private static readonly HashSet AllowedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".png" }; private static readonly HttpClient HttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(45.0), MaxResponseContentBufferSize = 26214400L }; private void Awake() { _enabled = ((BaseUnityPlugin)this).Config.Bind("General", "Enabled", true, "Enable automatic translation updates."); _autoUpdateOnStartup = ((BaseUnityPlugin)this).Config.Bind("General", "AutoUpdateOnStartup", true, "Check the remote translation repository shortly after the game starts."); _startupDelaySeconds = ((BaseUnityPlugin)this).Config.Bind("General", "StartupDelaySeconds", 1.5f, "Delay before the startup update check."); _commandPollSeconds = ((BaseUnityPlugin)this).Config.Bind("General", "CommandPollSeconds", 1.5f, "How often to poll commands.txt."); _deleteMissingFiles = ((BaseUnityPlugin)this).Config.Bind("Sync", "DeleteMissingFiles", false, "Delete local translation files that are not listed in the remote manifest."); _translationRootPath = ((BaseUnityPlugin)this).Config.Bind("Paths", "TranslationRoot", "Translation", "Translation root relative to BepInEx. Use config\\Translation for Thunderstore profiles."); _translationRoot = ResolveBepInExRelativePath(_translationRootPath.Value); _dataDirectory = Path.Combine(Paths.ConfigPath, "GWYF.TranslationUpdater"); _commandsPath = Path.Combine(_dataDirectory, "commands.txt"); _statusPath = Path.Combine(_dataDirectory, "status.txt"); _manifestCachePath = Path.Combine(_dataDirectory, "last_manifest.txt"); _readmePath = Path.Combine(_dataDirectory, "README.txt"); Directory.CreateDirectory(_dataDirectory); EnsureFileExists(_commandsPath, "# commands: update, force, status" + Environment.NewLine + "# update -> update only changed files" + Environment.NewLine + "# force -> re-download all files listed in manifest"); EnsureFileExists(_readmePath, "GWYF Translation Updater" + Environment.NewLine + "1. Remote source is locked to XoF-eLtTiL/GWYF-zhtw-Translation." + Environment.NewLine + "2. Put 'update' or 'force' into commands.txt to trigger a sync." + Environment.NewLine + "3. Only zh-TW/Text/*.txt and zh-TW/Texture/*.png are downloaded." + Environment.NewLine + "4. Files are written to " + _translationRoot + "." + Environment.NewLine + "5. The updater only writes files; it never executes downloaded content."); DeleteAutoGeneratedTranslationFiles(); WriteStatus("Translation updater ready."); } private void Start() { if (_enabled.Value && _autoUpdateOnStartup.Value) { float num = Mathf.Max(0f, _startupDelaySeconds.Value); if (num <= 0.01f) { _startupUpdateTriggered = true; WriteStatus("Startup translation sync requested immediately."); TriggerUpdate(force: false, "startup"); } else { _startupCheckAt = Time.unscaledTime + num; WriteStatus($"Startup translation sync scheduled in {num:0.##}s."); } } } private void Update() { if (_enabled.Value) { if (!_startupUpdateTriggered && _autoUpdateOnStartup.Value && Time.unscaledTime >= _startupCheckAt) { _startupUpdateTriggered = true; TriggerUpdate(force: false, "startup"); } if (Time.unscaledTime >= _nextCommandPollAt) { _nextCommandPollAt = Time.unscaledTime + Mathf.Max(0.25f, _commandPollSeconds.Value); ProcessCommandsIfChanged(); } } } private void ProcessCommandsIfChanged() { if (!File.Exists(_commandsPath)) { return; } DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(_commandsPath); if (lastWriteTimeUtc <= _lastCommandWriteTimeUtc) { return; } _lastCommandWriteTimeUtc = lastWriteTimeUtc; string[] array = File.ReadAllLines(_commandsPath); if (!array.Any((string line) => !string.IsNullOrWhiteSpace(line) && !line.TrimStart(Array.Empty()).StartsWith("#", StringComparison.Ordinal))) { return; } string[] array2 = array; for (int i = 0; i < array2.Length; i++) { string text = array2[i].Trim(); if (!string.IsNullOrWhiteSpace(text) && !text.StartsWith("#", StringComparison.Ordinal)) { switch (text.ToLowerInvariant()) { case "update": TriggerUpdate(force: false, "manual"); break; case "force": TriggerUpdate(force: true, "manual-force"); break; case "status": WriteStatus("Updater is idle."); break; default: WriteStatus("Unknown updater command: " + text); break; } } } File.WriteAllText(_commandsPath, "# commands processed" + Environment.NewLine); } private void TriggerUpdate(bool force, string reason) { if (_updateTask != null && !_updateTask.IsCompleted) { WriteStatus("Update already running. Ignored " + reason + " request."); } else { _updateTask = RunUpdateAsync(force, reason); } } private async Task RunUpdateAsync(bool force, string reason) { _ = 1; try { WriteStatus("Checking remote translations (" + reason + ")..."); string manifestText = await HttpClient.GetStringAsync("https://raw.githubusercontent.com/XoF-eLtTiL/GWYF-zhtw-Translation/refs/heads/main/manifest.txt"); List remoteEntries = ParseManifest(manifestText); if (remoteEntries.Count == 0) { WriteStatus("Remote manifest is empty."); return; } int downloaded = 0; foreach (ManifestEntry entry in remoteEntries) { if (!IsSafeManifestEntry(entry, out string reason2)) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Skipped unsafe manifest entry '" + entry.RelativePath + "': " + reason2)); continue; } string localPath = GetLocalPath(entry); if (!IsPathUnderRoot(localPath, _translationRoot)) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Skipped manifest entry outside translation root: " + entry.RelativePath)); continue; } string a = (File.Exists(localPath) ? ComputeFileSha256(localPath) : string.Empty); if (force || !string.Equals(a, entry.Sha256, StringComparison.OrdinalIgnoreCase)) { string text = BuildRemoteFileUrl(entry); byte[] array = await HttpClient.GetByteArrayAsync(text); if (array.LongLength != entry.Size) { WriteStatus("Size mismatch for " + entry.RelativePath + ". Skipped."); continue; } if (!string.Equals(ComputeSha256(array), entry.Sha256, StringComparison.OrdinalIgnoreCase)) { WriteStatus("Hash mismatch for " + entry.RelativePath + ". Skipped."); continue; } Directory.CreateDirectory(Path.GetDirectoryName(localPath) ?? _translationRoot); File.WriteAllBytes(localPath, array); downloaded++; ((BaseUnityPlugin)this).Logger.LogInfo((object)("Updated translation file: " + entry.RelativePath)); } } if (_deleteMissingFiles.Value) { DeleteFilesMissingFromManifest(remoteEntries); } DeleteAutoGeneratedTranslationFiles(); File.WriteAllText(_manifestCachePath, manifestText, Encoding.UTF8); if (downloaded > 0) { TouchTranslationFiles(); TryReloadXUnityTranslations(); } WriteStatus($"Translation sync complete. Updated {downloaded} file(s) from {remoteEntries.Count} manifest entries."); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)ex); WriteStatus("Translation sync failed: " + ex.Message); } } private string GetLocalPath(ManifestEntry entry) { string text = NormalizeRelativePath(entry.RelativePath); return Path.GetFullPath(Path.Combine(_translationRoot, text.Replace('/', Path.DirectorySeparatorChar))); } private static string ResolveBepInExRelativePath(string configuredPath) { string text = (configuredPath ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(text)) { text = "Translation"; } if (Path.IsPathRooted(text)) { return Path.GetFullPath(text); } string text2 = NormalizeRelativePath(text); if (text2.Equals("config", StringComparison.OrdinalIgnoreCase)) { return Paths.ConfigPath; } if (text2.StartsWith("config/", StringComparison.OrdinalIgnoreCase)) { string path = text2.Substring("config/".Length).Replace('/', Path.DirectorySeparatorChar); return Path.GetFullPath(Path.Combine(Paths.ConfigPath, path)); } return Path.GetFullPath(Path.Combine(Paths.GameRootPath, "BepInEx", text2.Replace('/', Path.DirectorySeparatorChar))); } private string BuildRemoteFileUrl(ManifestEntry entry) { string relativePath = NormalizeRelativePath(entry.RelativePath); return BuildRawFileUrl("https://raw.githubusercontent.com/XoF-eLtTiL/GWYF-zhtw-Translation/refs/heads/main/translations", relativePath); } private void DeleteFilesMissingFromManifest(List remoteEntries) { HashSet hashSet = new HashSet(remoteEntries.Select((ManifestEntry entry) => NormalizeRelativePath(entry.RelativePath)), StringComparer.OrdinalIgnoreCase); if (!Directory.Exists(_translationRoot)) { return; } string[] files = Directory.GetFiles(_translationRoot, "*", SearchOption.AllDirectories); foreach (string text in files) { string text2 = NormalizeRelativePath(GetRelativePathCompat(_translationRoot, text)); if (IsSafeRelativeDownloadPath(text2) && !hashSet.Contains(text2)) { File.Delete(text); ((BaseUnityPlugin)this).Logger.LogInfo((object)("Deleted translation file not listed in manifest: " + text2)); } } } private void DeleteAutoGeneratedTranslationFiles() { if (Directory.Exists(_translationRoot)) { string[] files = Directory.GetFiles(_translationRoot, "_AutoGeneratedTranslations.txt", SearchOption.AllDirectories); foreach (string text in files) { File.Delete(text); ((BaseUnityPlugin)this).Logger.LogInfo((object)("Deleted auto-generated translation file: " + NormalizeRelativePath(GetRelativePathCompat(_translationRoot, text)))); } } } private void TouchTranslationFiles() { if (!Directory.Exists(_translationRoot)) { return; } DateTime utcNow = DateTime.UtcNow; string[] files = Directory.GetFiles(_translationRoot, "*", SearchOption.AllDirectories); foreach (string text in files) { string text2 = NormalizeRelativePath(GetRelativePathCompat(_translationRoot, text)); if (IsSafeRelativeDownloadPath(text2)) { try { File.SetLastWriteTimeUtc(text, utcNow); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Could not touch translation file for reload: " + text2 + ". " + ex.Message)); } } } } private void TryReloadXUnityTranslations() { try { MethodInfo methodInfo = Type.GetType("XUnity.AutoTranslator.Plugin.Core.SettingsTranslationsInitializer, XUnity.AutoTranslator.Plugin.Core")?.GetMethod("LoadTranslations", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); if (methodInfo == null) { ((BaseUnityPlugin)this).Logger.LogInfo((object)"XUnity translation reload hook was not found; updated translations may apply after text refresh or game restart."); return; } methodInfo.Invoke(null, null); ((BaseUnityPlugin)this).Logger.LogInfo((object)"Requested XUnity translation reload after update."); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Could not request XUnity translation reload; updated translations may apply after text refresh or game restart. " + ex.Message)); } } private static List ParseManifest(string manifestText) { List list = new List(); string[] array = manifestText.Split(new string[2] { "\r\n", "\n" }, StringSplitOptions.None); for (int i = 0; i < array.Length; i++) { string text = array[i].Trim(); if (string.IsNullOrWhiteSpace(text) || text.StartsWith("#", StringComparison.Ordinal)) { continue; } string[] array2 = text.Split(new char[1] { '|' }); if (array2.Length >= 2) { string relativePath = NormalizeRelativePath(array2[0]); string sha = array2[1].Trim(); long result = 0L; if (array2.Length >= 3) { long.TryParse(array2[2].Trim(), out result); } list.Add(new ManifestEntry(relativePath, sha, result)); } } return list; } private static string NormalizeRelativePath(string path) { return (path ?? string.Empty).Trim().Replace('\\', '/'); } private static bool IsSafeManifestEntry(ManifestEntry entry, out string reason) { if (!IsSafeRelativeDownloadPath(NormalizeRelativePath(entry.RelativePath))) { reason = "path is outside the locked translation file allowlist"; return false; } if (!IsSha256Hex(entry.Sha256)) { reason = "sha256 is invalid"; return false; } if (entry.Size < 0 || entry.Size > 26214400) { reason = "size is invalid"; return false; } reason = string.Empty; return true; } private static bool IsSafeRelativeDownloadPath(string relativePath) { string relativePath2 = relativePath; if (string.IsNullOrWhiteSpace(relativePath2) || relativePath2.StartsWith("/", StringComparison.Ordinal) || relativePath2.StartsWith("\\", StringComparison.Ordinal) || relativePath2.IndexOf(':') >= 0 || relativePath2.Split(new char[1] { '/' }).Any((string part) => part == "." || part == ".." || string.IsNullOrWhiteSpace(part))) { return false; } if (!AllowedPathPrefixes.Any((string prefix) => relativePath2.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) { return false; } return AllowedExtensions.Contains(Path.GetExtension(relativePath2)); } private static bool IsPathUnderRoot(string candidatePath, string rootPath) { string fullPath = Path.GetFullPath(candidatePath); string value = AppendDirectorySeparator(Path.GetFullPath(rootPath)); return fullPath.StartsWith(value, StringComparison.OrdinalIgnoreCase); } private static bool IsSha256Hex(string value) { if (value.Length == 64) { return value.All((char ch) => (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')); } return false; } private static string BuildRawFileUrl(string baseUrl, string relativePath) { string text = baseUrl.TrimEnd(new char[1] { '/' }); string[] value = NormalizeRelativePath(relativePath).Split(new char[1] { '/' }, StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString).ToArray(); return text + "/" + string.Join("/", value); } private static string ComputeFileSha256(string filePath) { using FileStream inputStream = File.OpenRead(filePath); using SHA256 sHA = SHA256.Create(); return ConvertToHex(sHA.ComputeHash(inputStream)); } private static string ComputeSha256(byte[] content) { using SHA256 sHA = SHA256.Create(); return ConvertToHex(sHA.ComputeHash(content)); } private static string ConvertToHex(byte[] bytes) { StringBuilder stringBuilder = new StringBuilder(bytes.Length * 2); foreach (byte b in bytes) { stringBuilder.Append(b.ToString("x2")); } return stringBuilder.ToString(); } private static string GetRelativePathCompat(string basePath, string targetPath) { Uri uri = new Uri(AppendDirectorySeparator(basePath)); Uri uri2 = new Uri(targetPath); return Uri.UnescapeDataString(uri.MakeRelativeUri(uri2).ToString()).Replace('/', Path.DirectorySeparatorChar); } private static string AppendDirectorySeparator(string path) { char directorySeparatorChar; if (string.IsNullOrEmpty(path)) { directorySeparatorChar = Path.DirectorySeparatorChar; return directorySeparatorChar.ToString(); } directorySeparatorChar = Path.DirectorySeparatorChar; if (!path.EndsWith(directorySeparatorChar.ToString(), StringComparison.Ordinal)) { directorySeparatorChar = Path.DirectorySeparatorChar; return path + directorySeparatorChar; } return path; } private void WriteStatus(string message) { ((BaseUnityPlugin)this).Logger.LogInfo((object)message); File.WriteAllText(_statusPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}", Encoding.UTF8); } private static void EnsureFileExists(string path, string content) { if (!File.Exists(path)) { File.WriteAllText(path, content + Environment.NewLine, Encoding.UTF8); } } } }