diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-01-03 14:31:27 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-01-03 14:31:27 -0500 |
commit | 04c6733adae9ce568aefb5d9dee6101097e994c5 (patch) | |
tree | c93f0650f6f79a95016c29526f8af437ad91a815 /src/SMAPI/Framework | |
parent | 48bb1581a6adeabfefbdd774011796e09a07aae2 (diff) | |
parent | 2b3f0506a16622b25a702aae250e10005287c4f4 (diff) | |
download | SMAPI-04c6733adae9ce568aefb5d9dee6101097e994c5.tar.gz SMAPI-04c6733adae9ce568aefb5d9dee6101097e994c5.tar.bz2 SMAPI-04c6733adae9ce568aefb5d9dee6101097e994c5.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 23 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/GameContentManager.cs | 84 | ||||
-rw-r--r-- | src/SMAPI/Framework/CursorPosition.cs | 21 | ||||
-rw-r--r-- | src/SMAPI/Framework/DeprecationManager.cs | 5 | ||||
-rw-r--r-- | src/SMAPI/Framework/Input/SInputState.cs | 18 | ||||
-rw-r--r-- | src/SMAPI/Framework/Logging/LogManager.cs | 20 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModLoading/ModResolver.cs | 36 | ||||
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 4 | ||||
-rw-r--r-- | src/SMAPI/Framework/SMultiplayer.cs | 15 |
9 files changed, 195 insertions, 31 deletions
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index f9027972..3d5bb29d 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -54,6 +54,9 @@ namespace StardewModdingAPI.Framework /// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks> private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim(); + /// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary> + private readonly LocalizedContentManager VanillaContentManager; + /********* ** Accessors @@ -95,6 +98,7 @@ namespace StardewModdingAPI.Framework this.ContentManagers.Add( this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing, onLoadingFirstAsset) ); + this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormalizeAssetName, reflection); } @@ -150,6 +154,8 @@ namespace StardewModdingAPI.Framework { foreach (IContentManager contentManager in this.ContentManagers) contentManager.OnLocaleChanged(); + + this.VanillaContentManager.Unload(); }); } @@ -287,6 +293,23 @@ namespace StardewModdingAPI.Framework }); } + /// <summary>Get a vanilla asset without interception.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public bool TryLoadVanillaAsset<T>(string assetName, out T asset) + { + try + { + asset = this.VanillaContentManager.Load<T>(assetName); + return true; + } + catch + { + asset = default; + return false; + } + } + /// <summary>Dispose held resources.</summary> public void Dispose() { diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index ad8f2ef1..424d6ff3 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewValley; using xTile; +using xTile.Tiles; namespace StardewModdingAPI.Framework.ContentManagers { @@ -308,15 +309,10 @@ namespace StardewModdingAPI.Framework.ContentManagers return null; } - // validate asset - if (data == null) - { - mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); - return null; - } - // return matched asset - return new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); + return this.TryValidateLoadedAsset(info, data, mod) + ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName) + : null; } /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> @@ -386,5 +382,77 @@ namespace StardewModdingAPI.Framework.ContentManagers // return result return asset; } + + /// <summary>Validate that an asset loaded by a mod is valid and won't cause issues.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The basic asset metadata.</param> + /// <param name="data">The loaded asset data.</param> + /// <param name="mod">The mod which loaded the asset.</param> + private bool TryValidateLoadedAsset<T>(IAssetInfo info, T data, IModMetadata mod) + { + // can't load a null asset + if (data == null) + { + mod.LogAsMod($"SMAPI blocked asset replacement for '{info.AssetName}': mod incorrectly set asset to a null value.", LogLevel.Error); + return false; + } + + // when replacing a map, the vanilla tilesheets must have the same order and IDs + if (data is Map loadedMap && this.Coordinator.TryLoadVanillaAsset(info.AssetName, out Map vanillaMap)) + { + for (int i = 0; i < vanillaMap.TileSheets.Count; i++) + { + // check for match + TileSheet vanillaSheet = vanillaMap.TileSheets[i]; + bool found = this.TryFindTilesheet(loadedMap, vanillaSheet.Id, out int loadedIndex, out TileSheet loadedSheet); + if (found && loadedIndex == i) + continue; + + // handle mismatch + { + // only show warning if not farm map + // This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting. + bool isFarmMap = info.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining"); + + + string reason = found + ? $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\n\nTechnical details for mod author:\nExpected order [{string.Join(", ", vanillaMap.TileSheets.Select(p => $"'{p.ImageSource}' (id: {p.Id})"))}], but found tilesheet '{vanillaSheet.Id}' at index {loadedIndex} instead of {i}. Make sure custom tilesheet IDs are prefixed with 'z_' to avoid reordering tilesheets." + : $"mod has no tilesheet with ID '{vanillaSheet.Id}'. Map replacements must keep the original tilesheets to avoid errors or crashes."; + + SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval); + if (isFarmMap) + { + mod.LogAsMod($"SMAPI blocked asset replacement for '{info.AssetName}': {reason}", LogLevel.Error); + return false; + } + mod.LogAsMod($"SMAPI detected a potential issue with asset replacement for '{info.AssetName}' map: {reason}", LogLevel.Warn); + } + } + } + + return true; + } + + /// <summary>Find a map tilesheet by ID.</summary> + /// <param name="map">The map whose tilesheets to search.</param> + /// <param name="id">The tilesheet ID to match.</param> + /// <param name="index">The matched tilesheet index, if any.</param> + /// <param name="tilesheet">The matched tilesheet, if any.</param> + private bool TryFindTilesheet(Map map, string id, out int index, out TileSheet tilesheet) + { + for (int i = 0; i < map.TileSheets.Count; i++) + { + if (map.TileSheets[i].Id == id) + { + index = i; + tilesheet = map.TileSheets[i]; + return true; + } + } + + index = -1; + tilesheet = null; + return false; + } } } diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs index 80d89994..107481e7 100644 --- a/src/SMAPI/Framework/CursorPosition.cs +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -1,4 +1,5 @@ using Microsoft.Xna.Framework; +using StardewValley; namespace StardewModdingAPI.Framework { @@ -25,8 +26,8 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map, adjusted for pixel zoom.</param> - /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen, adjusted for pixel zoom.</param> + /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map, adjusted for zoom but not UI scaling.</param> + /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen, adjusted for zoom but not UI scaling.</param> /// <param name="tile">The tile position relative to the top-left corner of the map.</param> /// <param name="grabTile">The tile position that the game considers under the cursor for purposes of clicking actions.</param> public CursorPosition(Vector2 absolutePixels, Vector2 screenPixels, Vector2 tile, Vector2 grabTile) @@ -42,5 +43,21 @@ namespace StardewModdingAPI.Framework { return other != null && this.AbsolutePixels == other.AbsolutePixels; } + + /// <inheritdoc /> + public Vector2 GetScaledAbsolutePixels() + { + return Game1.uiMode + ? Utility.ModifyCoordinatesForUIScale(this.AbsolutePixels) + : this.AbsolutePixels; + } + + /// <inheritdoc /> + public Vector2 GetScaledScreenPixels() + { + return Game1.uiMode + ? Utility.ModifyCoordinatesForUIScale(this.ScreenPixels) + : this.ScreenPixels; + } } } diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index c22b5718..fc1b434b 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -63,6 +63,11 @@ namespace StardewModdingAPI.Framework this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity, Environment.StackTrace)); } + /// <summary>A placeholder method used to track deprecated code for which a separate warning will be shown.</summary> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="severity">How deprecated the code is.</param> + public void PlaceholderWarn(string version, DeprecationLevel severity) { } + /// <summary>Print any queued messages.</summary> public void PrintQueued() { diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index 23670202..a8d1f371 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -63,18 +63,16 @@ namespace StardewModdingAPI.Framework.Input base.Update(); // update SMAPI extended data + // note: Stardew Valley is *not* in UI mode when this code runs try { - float scale = Game1.options.uiScale; + float zoomMultiplier = (1f / Game1.options.zoomLevel); // get real values var controller = new GamePadStateBuilder(base.GetGamePadState()); var keyboard = new KeyboardStateBuilder(base.GetKeyboardState()); var mouse = new MouseStateBuilder(base.GetMouseState()); - Vector2 cursorAbsolutePos = new Vector2( - x: (mouse.X / scale) + Game1.uiViewport.X, - y: (mouse.Y / scale) + Game1.uiViewport.Y - ); + Vector2 cursorAbsolutePos = new Vector2((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y); Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null; HashSet<SButton> reallyDown = new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller)); @@ -109,7 +107,7 @@ namespace StardewModdingAPI.Framework.Input if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile) { this.LastPlayerTile = playerTilePos; - this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, scale); + this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, zoomMultiplier); } } catch (InvalidOperationException) @@ -202,11 +200,11 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Get the current cursor position.</summary> /// <param name="mouseState">The current mouse state.</param> /// <param name="absolutePixels">The absolute pixel position relative to the map, adjusted for pixel zoom.</param> - /// <param name="scale">The UI scale applied to pixel coordinates.</param> - private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float scale) + /// <param name="zoomMultiplier">The multiplier applied to pixel coordinates to adjust them for pixel zoom.</param> + private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float zoomMultiplier) { - Vector2 screenPixels = new Vector2(mouseState.X / scale, mouseState.Y / scale); - Vector2 tile = new Vector2((int)((Game1.uiViewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.uiViewport.Y + screenPixels.Y) / Game1.tileSize)); + Vector2 screenPixels = new Vector2(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier); + Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize)); Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton ? tile : Game1.player.GetGrabTile(); diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index ee013a85..e504218b 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -46,7 +46,7 @@ namespace StardewModdingAPI.Framework.Logging search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), replacement: #if SMAPI_FOR_WINDOWS - "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).", + "Oops! Steam achievements won't work because Steam isn't loaded. See 'Launch SMAPI through Steam or GOG Galaxy' in the install guide for more info: https://smapi.io/install.", #else "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.", #endif @@ -425,9 +425,11 @@ namespace StardewModdingAPI.Framework.Logging this.Monitor.Log($" ({mod.ErrorDetails})"); } - // find skipped dependencies - IModMetadata[] skippedDependencies; + // group mods + List<IModMetadata> skippedDependencies = new List<IModMetadata>(); + List<IModMetadata> otherSkippedMods = new List<IModMetadata>(); { + // track broken dependencies HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.HasID() select mod.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); foreach (IModMetadata mod in skippedMods) @@ -435,7 +437,15 @@ namespace StardewModdingAPI.Framework.Logging foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) skippedDependencyIds.Add(requiredId); } - skippedDependencies = skippedMods.Where(p => p.HasID() && skippedDependencyIds.Contains(p.Manifest.UniqueID)).ToArray(); + + // collect mod groups + foreach (IModMetadata mod in skippedMods) + { + if (mod.HasID() && skippedDependencyIds.Contains(mod.Manifest.UniqueID)) + skippedDependencies.Add(mod); + else + otherSkippedMods.Add(mod); + } } // log skipped mods @@ -451,7 +461,7 @@ namespace StardewModdingAPI.Framework.Logging this.Monitor.Newline(); } - foreach (IModMetadata mod in skippedMods.OrderBy(p => p.DisplayName)) + foreach (IModMetadata mod in otherSkippedMods.OrderBy(p => p.DisplayName)) LogSkippedMod(mod); this.Monitor.Newline(); } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 08df7b76..af7d90f6 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -72,7 +72,7 @@ namespace StardewModdingAPI.Framework.ModLoading switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Obsolete, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}"); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Obsolete, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}", this.GetTechnicalReasonForStatusOverride(mod)); continue; case ModStatus.AssumeBroken: @@ -102,7 +102,7 @@ namespace StardewModdingAPI.Framework.ModLoading error += $"version newer than {mod.DataRecord.StatusUpperVersion}"; error += " at " + string.Join(" or ", updateUrls); - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, error); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, error, this.GetTechnicalReasonForStatusOverride(mod)); } continue; } @@ -409,6 +409,38 @@ namespace StardewModdingAPI.Framework.ModLoading yield return new ModDependency(manifest.ContentPackFor.UniqueID, manifest.ContentPackFor.MinimumVersion, FindMod(manifest.ContentPackFor.UniqueID), isRequired: true); } + /// <summary>Get a technical message indicating why a mod's compatibility status was overridden, if applicable.</summary> + /// <param name="mod">The mod metadata.</param> + private string GetTechnicalReasonForStatusOverride(IModMetadata mod) + { + // get compatibility list record + var data = mod.DataRecord; + if (data == null) + return null; + + // get status label + string statusLabel = data.Status switch + { + ModStatus.AssumeBroken => "'assume broken'", + ModStatus.AssumeCompatible => "'assume compatible'", + ModStatus.Obsolete => "obsolete", + _ => data.Status.ToString() + }; + + // get reason + string[] reasons = new[] { mod.DataRecord.StatusReasonPhrase, mod.DataRecord.StatusReasonDetails } + .Where(p => !string.IsNullOrWhiteSpace(p)) + .ToArray(); + + // build message + return + $"marked {statusLabel} in SMAPI's internal compatibility list for " + + (mod.DataRecord.StatusUpperVersion != null ? $"versions up to {mod.DataRecord.StatusUpperVersion}" : "all versions") + + ": " + + (reasons.Any() ? string.Join(": ", reasons) : "no reason given") + + "."; + } + /********* ** Private models diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index e05213f0..5dc33828 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1128,7 +1128,7 @@ namespace StardewModdingAPI.Framework modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender // raise events - this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); + this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); } /// <summary>Constructor a content manager to read game content files.</summary> @@ -1532,7 +1532,7 @@ namespace StardewModdingAPI.Framework // validate status if (mod.Status == ModMetadataStatus.Failed) { - this.Monitor.Log($" Failed: {mod.Error}"); + this.Monitor.Log($" Failed: {mod.ErrorDetails ?? mod.Error}"); failReason = mod.FailReason; errorReasonPhrase = mod.Error; return false; diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index f3b5e9b9..2f89fce9 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -10,6 +10,7 @@ using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.Network; using StardewValley.SDKs; @@ -54,15 +55,25 @@ namespace StardewModdingAPI.Framework /// <summary>Whether to log network traffic.</summary> private readonly bool LogNetworkTraffic; + /// <summary>The backing field for <see cref="Peers"/>.</summary> + private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new(() => new Dictionary<long, MultiplayerPeer>()); + + /// <summary>The backing field for <see cref="HostPeer"/>.</summary> + private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new(); + /********* ** Accessors *********/ /// <summary>The metadata for each connected peer.</summary> - public IDictionary<long, MultiplayerPeer> Peers { get; } = new Dictionary<long, MultiplayerPeer>(); + public IDictionary<long, MultiplayerPeer> Peers => this.PeersImpl.Value; /// <summary>The metadata for the host player, if the current player is a farmhand.</summary> - public MultiplayerPeer HostPeer; + public MultiplayerPeer HostPeer + { + get => this.HostPeerImpl.Value; + private set => this.HostPeerImpl.Value = value; + } /********* |