diff --git a/docs/release-notes.md b/docs/release-notes.md index 9835dd64..0a4cd667 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -13,6 +13,7 @@ * For mod authors: * Added asset propagation for interior door sprites. + * Reduced performance impact of invalidating cached assets before a save is loaded. * Fixed assets changed by a mod not reapplied if playing in non-English, the changes are only applicable after the save is loaded, the player returns to title and reloads a save, and the game reloads the target asset before the save is loaded. ## 3.9.4 diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index b1b33cd6..5f70d0f7 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -38,6 +38,9 @@ namespace StardewModdingAPI set => Context.LoadStageForScreen.Value = value; } + /// Whether the in-game world is completely unloaded and not in the process of being loaded. The world may still exist in memory at this point, but should be ignored. + internal static bool IsWorldFullyUnloaded => Context.LoadStage == LoadStage.ReturningToTitle || Context.LoadStage == LoadStage.None; + /********* ** Accessors diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 6d2ff441..5d4855ef 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -341,11 +341,16 @@ namespace StardewModdingAPI.Framework // reload core game assets if (removedAssets.Any()) { - IDictionary propagated = this.CoreAssets.Propagate(removedAssets.ToDictionary(p => p.Key, p => p.Value)); // use an intercepted content manager - this.Monitor.Log($"Invalidated {removedAssets.Count} asset names ({string.Join(", ", removedAssets.Keys.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}); propagated {propagated.Count(p => p.Value)} core assets.", LogLevel.Trace); + IDictionary propagated = this.CoreAssets.Propagate(removedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded); + + string[] invalidatedKeys = removedAssets.Keys.ToArray(); + string[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); + + string FormatKeyList(IEnumerable keys) => string.Join(", ", keys.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + this.Monitor.Log($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)}); propagated {propagatedKeys.Length} core assets ({FormatKeyList(propagatedKeys)})."); } else - this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace); + this.Monitor.Log("Invalidated 0 cache entries."); return removedAssets.Keys; } @@ -391,7 +396,7 @@ namespace StardewModdingAPI.Framework return; this.IsDisposed = true; - this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point.", LogLevel.Trace); + this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point."); foreach (IContentManager contentManager in this.ContentManagers) contentManager.Dispose(); this.ContentManagers.Clear(); diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index ae56dc9c..debbaffd 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -79,8 +79,9 @@ namespace StardewModdingAPI.Metadata /// Reload one of the game's core assets (if applicable). /// The asset keys and types to reload. + /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. /// Returns a lookup of asset names to whether they've been propagated. - public IDictionary Propagate(IDictionary assets) + public IDictionary Propagate(IDictionary assets, bool ignoreWorld) { // group into optimized lists var buckets = assets.GroupBy(p => @@ -101,16 +102,18 @@ namespace StardewModdingAPI.Metadata switch (bucket.Key) { case AssetBucket.Sprite: - this.ReloadNpcSprites(bucket.Select(p => p.Key), propagated); + if (!ignoreWorld) + this.ReloadNpcSprites(bucket.Select(p => p.Key), propagated); break; case AssetBucket.Portrait: - this.ReloadNpcPortraits(bucket.Select(p => p.Key), propagated); + if (!ignoreWorld) + this.ReloadNpcPortraits(bucket.Select(p => p.Key), propagated); break; default: foreach (var entry in bucket) - propagated[entry.Key] = this.PropagateOther(entry.Key, entry.Value); + propagated[entry.Key] = this.PropagateOther(entry.Key, entry.Value, ignoreWorld); break; } } @@ -124,9 +127,10 @@ namespace StardewModdingAPI.Metadata /// Reload one of the game's core assets (if applicable). /// The asset key to reload. /// The asset type to reload. + /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. /// Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true. [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")] - private bool PropagateOther(string key, Type type) + private bool PropagateOther(string key, Type type, bool ignoreWorld) { var content = this.MainContentManager; key = this.AssertAndNormalizeAssetName(key); @@ -136,7 +140,7 @@ namespace StardewModdingAPI.Metadata ** We only need to do this for the current location, since tilesheets are reloaded when you enter a location. ** Just in case, we should still propagate by key even if a tilesheet is matched. ****/ - if (Game1.currentLocation?.map?.TileSheets != null) + if (!ignoreWorld && Game1.currentLocation?.map?.TileSheets != null) { foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { @@ -151,14 +155,19 @@ namespace StardewModdingAPI.Metadata if (type == typeof(Map)) { bool anyChanged = false; - foreach (GameLocation location in this.GetLocations()) + + if (!ignoreWorld) { - if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) + foreach (GameLocation location in this.GetLocations()) { - this.ReloadMap(location); - anyChanged = true; + if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) + { + this.ReloadMap(location); + anyChanged = true; + } } } + return anyChanged; } @@ -172,7 +181,7 @@ namespace StardewModdingAPI.Metadata ** Animals ****/ case "animals\\horse": - return this.ReloadPetOrHorseSprites(content, key); + return !ignoreWorld && this.ReloadPetOrHorseSprites(content, key); /**** ** Buildings @@ -197,7 +206,7 @@ namespace StardewModdingAPI.Metadata case "characters\\farmer\\farmer_base_bald": case "characters\\farmer\\farmer_girl_base": case "characters\\farmer\\farmer_girl_base_bald": - return this.ReloadPlayerSprites(key); + return !ignoreWorld && this.ReloadPlayerSprites(key); case "characters\\farmer\\hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hairStylesTexture, key); @@ -270,7 +279,7 @@ namespace StardewModdingAPI.Metadata return true; case "data\\farmanimals": // FarmAnimal constructor - return this.ReloadFarmAnimalData(); + return !ignoreWorld && this.ReloadFarmAnimalData(); case "data\\hairdata": // Farmer.GetHairStyleMetadataFile return this.ReloadHairData(); @@ -288,7 +297,7 @@ namespace StardewModdingAPI.Metadata return true; case "data\\npcdispositions": // NPC constructor - return this.ReloadNpcDispositions(content, key); + return !ignoreWorld && this.ReloadNpcDispositions(content, key); case "data\\npcgifttastes": // Game1.LoadContent Game1.NPCGiftTastes = content.Load>(key); @@ -367,7 +376,8 @@ namespace StardewModdingAPI.Metadata button.texture = Game1.mouseCursors; } - this.ReloadDoorSprites(content, key); + if (!ignoreWorld) + this.ReloadDoorSprites(content, key); return true; case "loosesprites\\cursors2": // Game1.LoadContent @@ -395,7 +405,7 @@ namespace StardewModdingAPI.Metadata return true; case "loosesprites\\suspensionbridge": // SuspensionBridge constructor - return this.ReloadSuspensionBridges(content, key); + return !ignoreWorld && this.ReloadSuspensionBridges(content, key); /**** ** Content\Maps @@ -454,14 +464,14 @@ namespace StardewModdingAPI.Metadata return true; case "tilesheets\\chairtiles": // Game1.LoadContent - return this.ReloadChairTiles(content, key); + return this.ReloadChairTiles(content, key, ignoreWorld); case "tilesheets\\craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load(key); return true; case "tilesheets\\critters": // Critter constructor - return this.ReloadCritterTextures(content, key) > 0; + return !ignoreWorld && this.ReloadCritterTextures(content, key) > 0; case "tilesheets\\crops": // Game1.LoadContent Game1.cropSpriteSheet = content.Load(key); @@ -515,7 +525,7 @@ namespace StardewModdingAPI.Metadata return true; case "terrainfeatures\\grass": // from Grass - return this.ReloadGrassTextures(content, key); + return !ignoreWorld && this.ReloadGrassTextures(content, key); case "terrainfeatures\\hoedirt": // from HoeDirt HoeDirt.lightTexture = content.Load(key); @@ -530,52 +540,55 @@ namespace StardewModdingAPI.Metadata return true; case "terrainfeatures\\mushroom_tree": // from Tree - return this.ReloadTreeTextures(content, key, Tree.mushroomTree); + return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.mushroomTree); case "terrainfeatures\\tree_palm": // from Tree - return this.ReloadTreeTextures(content, key, Tree.palmTree); + return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.palmTree); case "terrainfeatures\\tree1_fall": // from Tree case "terrainfeatures\\tree1_spring": // from Tree case "terrainfeatures\\tree1_summer": // from Tree case "terrainfeatures\\tree1_winter": // from Tree - return this.ReloadTreeTextures(content, key, Tree.bushyTree); + return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.bushyTree); case "terrainfeatures\\tree2_fall": // from Tree case "terrainfeatures\\tree2_spring": // from Tree case "terrainfeatures\\tree2_summer": // from Tree case "terrainfeatures\\tree2_winter": // from Tree - return this.ReloadTreeTextures(content, key, Tree.leafyTree); + return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.leafyTree); case "terrainfeatures\\tree3_fall": // from Tree case "terrainfeatures\\tree3_spring": // from Tree case "terrainfeatures\\tree3_winter": // from Tree - return this.ReloadTreeTextures(content, key, Tree.pineTree); + return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.pineTree); } /**** ** Dynamic assets ****/ - // dynamic textures - if (this.KeyStartsWith(key, "animals\\cat")) - return this.ReloadPetOrHorseSprites(content, key); - if (this.KeyStartsWith(key, "animals\\dog")) - return this.ReloadPetOrHorseSprites(content, key); - if (this.IsInFolder(key, "Animals")) - return this.ReloadFarmAnimalSprites(content, key); + if (!ignoreWorld) + { + // dynamic textures + if (this.KeyStartsWith(key, "animals\\cat")) + return this.ReloadPetOrHorseSprites(content, key); + if (this.KeyStartsWith(key, "animals\\dog")) + return this.ReloadPetOrHorseSprites(content, key); + if (this.IsInFolder(key, "Animals")) + return this.ReloadFarmAnimalSprites(content, key); - if (this.IsInFolder(key, "Buildings")) - return this.ReloadBuildings(content, key); + if (this.IsInFolder(key, "Buildings")) + return this.ReloadBuildings(content, key); - if (this.KeyStartsWith(key, "LooseSprites\\Fence")) - return this.ReloadFenceTextures(key); + if (this.KeyStartsWith(key, "LooseSprites\\Fence")) + return this.ReloadFenceTextures(key); - // dynamic data - if (this.IsInFolder(key, "Characters\\Dialogue")) - return this.ReloadNpcDialogue(key); + // dynamic data + if (this.IsInFolder(key, "Characters\\Dialogue")) + return this.ReloadNpcDialogue(key); - if (this.IsInFolder(key, "Characters\\schedules")) - return this.ReloadNpcSchedules(key); + if (this.IsInFolder(key, "Characters\\schedules")) + return this.ReloadNpcSchedules(key); + } return false; } @@ -695,19 +708,23 @@ namespace StardewModdingAPI.Metadata /// Reload map seat textures. /// The content manager through which to reload the asset. /// The asset key to reload. + /// Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world. /// Returns whether any textures were reloaded. - private bool ReloadChairTiles(LocalizedContentManager content, string key) + private bool ReloadChairTiles(LocalizedContentManager content, string key, bool ignoreWorld) { MapSeat.mapChairTexture = content.Load(key); - foreach (var location in this.GetLocations()) + if (!ignoreWorld) { - foreach (MapSeat seat in location.mapSeats.Where(p => p != null)) + foreach (var location in this.GetLocations()) { - string curKey = this.NormalizeAssetNameIgnoringEmpty(seat._loadedTextureFile); + foreach (MapSeat seat in location.mapSeats.Where(p => p != null)) + { + string curKey = this.NormalizeAssetNameIgnoringEmpty(seat._loadedTextureFile); - if (curKey == null || key.Equals(curKey, StringComparison.OrdinalIgnoreCase)) - seat.overlayTexture = MapSeat.mapChairTexture; + if (curKey == null || key.Equals(curKey, StringComparison.OrdinalIgnoreCase)) + seat.overlayTexture = MapSeat.mapChairTexture; + } } }