diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2019-11-24 13:49:30 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2019-11-24 13:49:30 -0500 |
commit | a3f21685049cabf2d824c8060dc0b1de47e9449e (patch) | |
tree | ad9add30e9da2a50e0ea0245f1546b7378f0d282 /src/SMAPI/Framework/SCore.cs | |
parent | 6521df7b131924835eb797251c1e956fae0d6e13 (diff) | |
parent | 277bf082675b98b95bf6184fe3c7a45b969c7ac2 (diff) | |
download | SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.tar.gz SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.tar.bz2 SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI/Framework/SCore.cs')
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 400 |
1 files changed, 201 insertions, 199 deletions
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 5dd52992..afb82679 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -24,13 +24,12 @@ using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.Serialisation; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Framework.Serialization; using StardewModdingAPI.Patches; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using Object = StardewValley.Object; @@ -38,7 +37,7 @@ using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI.Framework { - /// <summary>The core class which initialises and manages SMAPI.</summary> + /// <summary>The core class which initializes and manages SMAPI.</summary> internal class SCore : IDisposable { /********* @@ -56,12 +55,15 @@ namespace StardewModdingAPI.Framework /// <summary>The core logger and monitor on behalf of the game.</summary> private readonly Monitor MonitorForGame; - /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> - private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> + private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection = new Reflector(); + /// <summary>Encapsulates access to SMAPI core translations.</summary> + private readonly Translator Translator = new Translator(); + /// <summary>The SMAPI configuration settings.</summary> private readonly SConfig Settings; @@ -72,7 +74,7 @@ namespace StardewModdingAPI.Framework private ContentCoordinator ContentCore => this.GameInstance.ContentCore; /// <summary>Tracks the installed mods.</summary> - /// <remarks>This is initialised after the game starts.</remarks> + /// <remarks>This is initialized after the game starts.</remarks> private readonly ModRegistry ModRegistry = new ModRegistry(); /// <summary>Manages SMAPI events for mods.</summary> @@ -84,15 +86,14 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the program has been disposed.</summary> private bool IsDisposed; - /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> + /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> private readonly Regex[] SuppressConsolePatterns = { new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) }; /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> @@ -120,7 +121,7 @@ namespace StardewModdingAPI.Framework ** Accessors *********/ /// <summary>Manages deprecation warnings.</summary> - /// <remarks>This is initialised after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> + /// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> internal static DeprecationManager DeprecationManager { get; private set; } @@ -144,7 +145,7 @@ namespace StardewModdingAPI.Framework // init basics this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { WriteToConsole = writeToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, @@ -165,6 +166,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + // log custom settings + { + IDictionary<string, object> customSettings = this.Settings.GetCustomSettings(); + if (customSettings.Any()) + this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}", LogLevel.Trace); + } + // validate platform #if SMAPI_FOR_WINDOWS if (Constants.Platform != Platform.Windows) @@ -187,27 +195,9 @@ namespace StardewModdingAPI.Framework [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions public void RunInteractively() { - // initialise SMAPI + // initialize SMAPI try { -#if !SMAPI_3_0_STRICT - // hook up events - ContentEvents.Init(this.EventManager); - ControlEvents.Init(this.EventManager); - GameEvents.Init(this.EventManager); - GraphicsEvents.Init(this.EventManager); - InputEvents.Init(this.EventManager); - LocationEvents.Init(this.EventManager); - MenuEvents.Init(this.EventManager); - MineEvents.Init(this.EventManager); - MultiplayerEvents.Init(this.EventManager); - PlayerEvents.Init(this.EventManager); - SaveEvents.Init(this.EventManager); - SpecialisedEvents.Init(this.EventManager); - TimeEvents.Init(this.EventManager); -#endif - - // init JSON parser JsonConverter[] converters = { new ColorConverter(), new PointConverter(), @@ -223,12 +213,29 @@ namespace StardewModdingAPI.Framework #endif AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); - // add more leniant assembly resolvers + // add more lenient assembly resolvers AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + // hook locale event + LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged(); + // override game - SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); - this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, SCore.DeprecationManager, this.OnLocaleChanged, this.InitialiseAfterGameStart, this.Dispose); + SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded); + this.GameInstance = new SGame( + monitor: this.Monitor, + monitorForGame: this.MonitorForGame, + reflection: this.Reflection, + translator: this.Translator, + eventManager: this.EventManager, + jsonHelper: this.Toolkit.JsonHelper, + modRegistry: this.ModRegistry, + deprecationManager: SCore.DeprecationManager, + onGameInitialized: this.InitializeAfterGameStart, + onGameExiting: this.Dispose, + cancellationToken: this.CancellationToken, + logNetworkTraffic: this.Settings.LogNetworkTraffic + ); + this.Translator.SetLocale(this.GameInstance.ContentCore.GetLocale(), this.GameInstance.ContentCore.Language); StardewValley.Program.gamePtr = this.GameInstance; // apply game patches @@ -236,13 +243,14 @@ namespace StardewModdingAPI.Framework new EventErrorPatch(this.MonitorForGame), new DialogueErrorPatch(this.MonitorForGame, this.Reflection), new ObjectErrorPatch(), - new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged) + new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged), + new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved) ); // add exit handler new Thread(() => { - this.CancellationTokenSource.Token.WaitHandle.WaitOne(); + this.CancellationToken.Token.WaitHandle.WaitOne(); if (this.IsGameRunning) { try @@ -262,14 +270,10 @@ namespace StardewModdingAPI.Framework // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; -#if SMAPI_3_0_STRICT - this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]"; - Console.Title += " [SMAPI 3.0 strict mode]"; -#endif } catch (Exception ex) { - this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"SMAPI failed to initialize: {ex.GetLogSummary()}", LogLevel.Error); this.PressAnyKeyToExit(); return; } @@ -302,6 +306,19 @@ namespace StardewModdingAPI.Framework File.Delete(Constants.FatalCrashMarker); } + // add headers + if (this.Settings.DeveloperMode) + this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (!this.Settings.CheckForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + this.Monitor.VerboseLog("Verbose logging enabled."); + + // update window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + // start game this.Monitor.Log("Starting game...", LogLevel.Debug); try @@ -359,7 +376,7 @@ namespace StardewModdingAPI.Framework this.IsGameRunning = false; this.ConsoleManager?.Dispose(); this.ContentCore?.Dispose(); - this.CancellationTokenSource?.Dispose(); + this.CancellationToken?.Dispose(); this.GameInstance?.Dispose(); this.LogFile?.Dispose(); @@ -371,24 +388,14 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ - /// <summary>Initialise SMAPI and mods after the game starts.</summary> - private void InitialiseAfterGameStart() + /// <summary>Initialize mods before the first game asset is loaded. At this point the core content managers are loaded (so mods can load their own assets), but the game is mostly uninitialized.</summary> + private void InitializeBeforeFirstAssetLoaded() { - // add headers -#if SMAPI_3_0_STRICT - this.Monitor.Log($"You're running SMAPI 3.0 strict mode, so most mods won't work correctly. If that wasn't intended, install the normal version of SMAPI from https://smapi.io instead.", LogLevel.Warn); -#endif - if (this.Settings.DeveloperMode) - this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); - if (!this.Settings.CheckForUpdates) - this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); - if (!this.Monitor.WriteToConsole) - this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); - this.Monitor.VerboseLog("Verbose logging enabled."); - - // validate XNB integrity - if (!this.ValidateContentIntegrity()) - this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); + if (this.CancellationToken.IsCancellationRequested) + { + this.Monitor.Log("SMAPI shutting down: aborting initialization.", LogLevel.Warn); + return; + } // load mod data ModToolkit toolkit = new ModToolkit(); @@ -399,12 +406,19 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); ModResolver resolver = new ModResolver(); + // log loose files + { + string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray(); + if (looseFiles.Any()) + this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}", LogLevel.Trace); + } + // load manifests IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); // filter out ignored mods foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) - this.Monitor.Log($" Skipped {mod.RelativeDirectoryPath} (folder name starts with a dot).", LogLevel.Trace); + this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).", LogLevel.Trace); mods = mods.Where(p => !p.IsIgnored).ToArray(); // load mods @@ -429,21 +443,19 @@ namespace StardewModdingAPI.Framework // check for updates this.CheckForUpdatesAsync(mods); } - if (this.Monitor.IsExiting) - { - this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); - return; - } // update window titles int modsLoaded = this.ModRegistry.GetAll().Count(); this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; -#if SMAPI_3_0_STRICT - this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]"; - Console.Title += " [SMAPI 3.0 strict mode]"; -#endif + } + /// <summary>Initialize SMAPI and mods after the game starts.</summary> + private void InitializeAfterGameStart() + { + // validate XNB integrity + if (!this.ValidateContentIntegrity()) + this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // start SMAPI console new Thread(this.RunConsoleLoop).Start(); @@ -452,13 +464,18 @@ namespace StardewModdingAPI.Framework /// <summary>Handle the game changing locale.</summary> private void OnLocaleChanged() { + this.ContentCore.OnLocaleChanged(); + // get locale string locale = this.ContentCore.GetLocale(); LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + // update core translations + this.Translator.SetLocale(locale, languageCode); + // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) - (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + foreach (IModMetadata mod in this.ModRegistry.GetAll()) + mod.Translations.SetLocale(locale, languageCode); } /// <summary>Run a loop handling console input.</summary> @@ -488,7 +505,7 @@ namespace StardewModdingAPI.Framework inputThread.Start(); // keep console thread alive while the game is running - while (this.IsGameRunning && !this.Monitor.IsExiting) + while (this.IsGameRunning && !this.CancellationToken.IsCancellationRequested) Thread.Sleep(1000 / 10); if (inputThread.ThreadState == ThreadState.Running) inputThread.Abort(); @@ -570,27 +587,19 @@ namespace StardewModdingAPI.Framework ISemanticVersion updateFound = null; try { - ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; - ISemanticVersion latestStable = response.Main?.Version; - ISemanticVersion latestBeta = response.Optional?.Version; + // fetch update check + ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", Constants.ApiVersion, new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value; + if (response.SuggestedUpdate != null) + this.Monitor.Log($"You can update SMAPI to {response.SuggestedUpdate.Version}: {Constants.HomePageUrl}", LogLevel.Alert); + else + this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); - if (latestStable == null && response.Errors.Any()) + // show errors + if (response.Errors.Any()) { this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}", LogLevel.Trace); } - else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) - { - updateFound = latestBeta; - this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) - { - updateFound = latestStable; - this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); } catch (Exception ex) { @@ -623,12 +632,12 @@ namespace StardewModdingAPI.Framework .GetUpdateKeys(validOnly: true) .Select(p => p.ToString()) .ToArray(); - searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.ToArray())); + searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, mod.Manifest.Version, updateKeys.ToArray(), isBroken: mod.Status == ModMetadataStatus.Failed)); } // fetch results this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); - IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); + IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray(), apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform); // extract update alerts & errors var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); @@ -649,20 +658,9 @@ namespace StardewModdingAPI.Framework ); } - // parse versions - bool useBetaInfo = result.HasBetaInfo && Constants.ApiVersion.IsPrerelease(); - ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; - ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; - ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; - ISemanticVersion unofficialVersion = useBetaInfo ? result.UnofficialForBeta?.Version : result.Unofficial?.Version; - - // show update alerts - if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) - updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); - else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) - updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); - else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) - updates.Add(Tuple.Create(mod, unofficialVersion, useBetaInfo ? result.UnofficialForBeta?.Url : result.Unofficial?.Url)); + // handle update + if (result.SuggestedUpdate != null) + updates.Add(Tuple.Create(mod, result.SuggestedUpdate.Version, result.SuggestedUpdate.Url)); } // show update errors @@ -697,18 +695,6 @@ namespace StardewModdingAPI.Framework }).Start(); } - /// <summary>Get whether a given version should be offered to the user as an update.</summary> - /// <param name="currentVersion">The current semantic version.</param> - /// <param name="newVersion">The target semantic version.</param> - /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> - private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) - { - return - newVersion != null - && newVersion.IsNewerThan(currentVersion) - && (useBetaChannel || !newVersion.IsPrerelease()); - } - /// <summary>Create a directory path if it doesn't exist.</summary> /// <param name="path">The directory path.</param> private void VerifyPath(string path) @@ -720,7 +706,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - // note: this happens before this.Monitor is initialised + // note: this happens before this.Monitor is initialized Console.WriteLine($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}"); } } @@ -755,8 +741,9 @@ namespace StardewModdingAPI.Framework LogSkip(contentPack, errorPhrase, errorDetails); } } - IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); - IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); + IModMetadata[] loaded = this.ModRegistry.GetAll().ToArray(); + IModMetadata[] loadedContentPacks = loaded.Where(p => p.IsContentPack).ToArray(); + IModMetadata[] loadedMods = loaded.Where(p => !p.IsContentPack).ToArray(); // unlock content packs this.ModRegistry.AreAllModsLoaded = true; @@ -796,12 +783,12 @@ namespace StardewModdingAPI.Framework } // log mod warnings - this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); + this.LogModWarnings(loaded, skippedMods); - // initialise translations - this.ReloadTranslations(loadedMods); + // initialize translations + this.ReloadTranslations(loaded); - // initialise loaded non-content-pack mods + // initialize loaded non-content-pack mods foreach (IModMetadata metadata in loadedMods) { // add interceptors @@ -850,7 +837,7 @@ namespace StardewModdingAPI.Framework } // invalidate cache entries when needed - // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) + // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialize.) foreach (IModMetadata metadata in loadedMods) { if (metadata.Mod.Helper.Content is ContentHelper helper) @@ -884,7 +871,7 @@ namespace StardewModdingAPI.Framework } // unlock mod integrations - this.ModRegistry.AreAllModsInitialised = true; + this.ModRegistry.AreAllModsInitialized = true; } /// <summary>Load a given mod.</summary> @@ -905,13 +892,13 @@ namespace StardewModdingAPI.Framework // log entry { - string relativePath = PathUtilities.GetRelativePath(this.ModsPath, mod.DirectoryPath); + string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) - this.Monitor.Log($" {mod.DisplayName} ({relativePath}) [content pack]...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]...", LogLevel.Trace); else if (mod.Manifest?.EntryDll != null) - this.Monitor.Log($" {mod.DisplayName} ({relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid else - this.Monitor.Log($" {mod.DisplayName} ({relativePath})...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath})...", LogLevel.Trace); } // add warning for missing update key @@ -926,16 +913,8 @@ namespace StardewModdingAPI.Framework return false; } -#if !SMAPI_3_0_STRICT - // add deprecation warning for old version format - { - if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat) - SCore.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.PendingRemoval); - } -#endif - // validate dependencies - // Although dependences are validated before mods are loaded, a dependency may have failed to load. + // Although dependencies are validated before mods are loaded, a dependency may have failed to load. if (mod.Manifest.Dependencies?.Any() == true) { foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) @@ -957,8 +936,9 @@ namespace StardewModdingAPI.Framework IManifest manifest = mod.Manifest; IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); - IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, jsonHelper); - mod.SetMod(contentPack, monitor); + TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper); + mod.SetMod(contentPack, monitor, translationHelper); this.ModRegistry.Add(mod); errorReasonPhrase = null; @@ -998,7 +978,7 @@ namespace StardewModdingAPI.Framework return false; } - // initialise mod + // initialize mod try { // get mod instance @@ -1020,8 +1000,17 @@ namespace StardewModdingAPI.Framework // init mod helpers IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { + IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); + } + IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); @@ -1030,16 +1019,8 @@ namespace StardewModdingAPI.Framework IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); - ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); - - IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); - } - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod @@ -1048,13 +1029,13 @@ namespace StardewModdingAPI.Framework modEntry.Monitor = monitor; // track mod - mod.SetMod(modEntry); + mod.SetMod(modEntry, translationHelper); this.ModRegistry.Add(mod); return true; } catch (Exception ex) { - errorReasonPhrase = $"initialisation failed:\n{ex.GetLogSummary()}"; + errorReasonPhrase = $"initialization failed:\n{ex.GetLogSummary()}"; return false; } } @@ -1063,7 +1044,7 @@ namespace StardewModdingAPI.Framework /// <summary>Write a summary of mod warnings to the console and log.</summary> /// <param name="mods">The loaded mods.</param> /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) + private void LogModWarnings(IEnumerable<IModMetadata> mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) { // get mods with warnings IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); @@ -1129,8 +1110,8 @@ namespace StardewModdingAPI.Framework "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", "errors, or crashes in-game." ); - LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", - "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", + LogWarningGroup(ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + "These mods change the save serializer. They may corrupt your save files, or make them unusable if", "you uninstall these mods." ); if (this.Settings.ParanoidWarnings) @@ -1200,64 +1181,85 @@ namespace StardewModdingAPI.Framework /// <param name="mods">The mods for which to reload translations.</param> private void ReloadTranslations(IEnumerable<IModMetadata> mods) { - JsonHelper jsonHelper = this.Toolkit.JsonHelper; + // core SMAPI translations + { + var translations = this.ReadTranslationFiles(Path.Combine(Constants.InternalFilesPath, "i18n"), out IList<string> errors); + if (errors.Any() || !translations.Any()) + { + this.Monitor.Log("SMAPI couldn't load some core translations. You may need to reinstall SMAPI.", LogLevel.Warn); + foreach (string error in errors) + this.Monitor.Log($" - {error}", LogLevel.Warn); + } + this.Translator.SetTranslations(translations); + } + + // mod translations foreach (IModMetadata metadata in mods) { - if (metadata.IsContentPack) - throw new InvalidOperationException("Can't reload translations for a content pack."); + var translations = this.ReadTranslationFiles(Path.Combine(metadata.DirectoryPath, "i18n"), out IList<string> errors); + if (errors.Any()) + { + metadata.LogAsMod("Mod couldn't load some translation files:", LogLevel.Warn); + foreach (string error in errors) + metadata.LogAsMod($" - {error}", LogLevel.Warn); + } + metadata.Translations.SetTranslations(translations); + } + } + + /// <summary>Read translations from a directory containing JSON translation files.</summary> + /// <param name="folderPath">The folder path to search.</param> + /// <param name="errors">The errors indicating why translation files couldn't be parsed, indexed by translation filename.</param> + private IDictionary<string, IDictionary<string, string>> ReadTranslationFiles(string folderPath, out IList<string> errors) + { + JsonHelper jsonHelper = this.Toolkit.JsonHelper; - // read translation files - IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); - DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); - if (translationsDir.Exists) + // read translation files + var translations = new Dictionary<string, IDictionary<string, string>>(); + errors = new List<string>(); + DirectoryInfo translationsDir = new DirectoryInfo(folderPath); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) { - foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try { - string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); - try - { - if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) - translations[locale] = data; - else - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn); - } - catch (Exception ex) + if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) { - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn); + errors.Add($"{file.Name} file couldn't be read"); // should never happen, since we're iterating files that exist + continue; } - } - } - // validate translations - foreach (string locale in translations.Keys.ToArray()) - { - // skip empty files - if (translations[locale] == null || !translations[locale].Keys.Any()) + translations[locale] = data; + } + catch (Exception ex) { - metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); - translations.Remove(locale); + errors.Add($"{file.Name} file couldn't be parsed: {ex.GetLogSummary()}"); continue; } + } + } - // handle duplicates - HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - foreach (string key in translations[locale].Keys.ToArray()) + // validate translations + foreach (string locale in translations.Keys.ToArray()) + { + // handle duplicates + HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in translations[locale].Keys.ToArray()) + { + if (!keys.Add(key)) { - if (!keys.Add(key)) - { - duplicateKeys.Add(key); - translations[locale].Remove(key); - } + duplicateKeys.Add(key); + translations[locale].Remove(key); } - if (duplicateKeys.Any()) - metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); } - - // update translation - TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; - translationHelper.SetTranslations(translations); + if (duplicateKeys.Any()) + errors.Add($"{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive."); } + + return translations; } /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> @@ -1298,7 +1300,7 @@ namespace StardewModdingAPI.Framework break; default: - throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); + throw new NotSupportedException($"Unrecognized core SMAPI command '{name}'."); } } @@ -1351,7 +1353,7 @@ namespace StardewModdingAPI.Framework /// <param name="name">The name of the module which will log messages with this instance.</param> private Monitor GetSecondaryMonitor(string name) { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + return new Monitor(name, this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { WriteToConsole = this.Monitor.WriteToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, |