using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Microsoft.Xna.Framework.Graphics; using Netcode; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Buildings; using StardewValley.Characters; using StardewValley.GameData.Movies; using StardewValley.Locations; using StardewValley.Menus; using StardewValley.Network; using StardewValley.Objects; using StardewValley.Projectiles; using StardewValley.TerrainFeatures; using xTile; using xTile.Tiles; namespace StardewModdingAPI.Metadata { /// Propagates changes to core assets to the game state. internal class CoreAssetPropagator { /********* ** Fields *********/ /// The main content manager through which to reload assets. private readonly LocalizedContentManager MainContentManager; /// An internal content manager used only for asset propagation. See remarks on . private readonly GameContentManagerForAssetPropagation DisposableContentManager; /// Writes messages to the console. private readonly IMonitor Monitor; /// Simplifies access to private game code. private readonly Reflector Reflection; /// Whether to enable more aggressive memory optimizations. private readonly bool AggressiveMemoryOptimizations; /// Normalizes an asset key to match the cache key and assert that it's valid. private readonly Func AssertAndNormalizeAssetName; /// Optimized bucket categories for batch reloading assets. private enum AssetBucket { /// NPC overworld sprites. Sprite, /// Villager dialogue portraits. Portrait, /// Any other asset. Other }; /********* ** Public methods *********/ /// Initialize the core asset data. /// The main content manager through which to reload assets. /// An internal content manager used only for asset propagation. /// Writes messages to the console. /// Simplifies access to private code. /// Whether to enable more aggressive memory optimizations. public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, bool aggressiveMemoryOptimizations) { this.MainContentManager = mainContent; this.DisposableContentManager = disposableContent; this.Monitor = monitor; this.Reflection = reflection; this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; this.AssertAndNormalizeAssetName = disposableContent.AssertAndNormalizeAssetName; } /// 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. /// A lookup of asset names to whether they've been propagated. /// Whether the NPC pathfinding cache was reloaded. public void Propagate(IDictionary assets, bool ignoreWorld, out IDictionary propagatedAssets, out bool updatedNpcWarps) { // group into optimized lists var buckets = assets.GroupBy(p => { if (this.IsInFolder(p.Key, "Characters") || this.IsInFolder(p.Key, "Characters\\Monsters")) return AssetBucket.Sprite; if (this.IsInFolder(p.Key, "Portraits")) return AssetBucket.Portrait; return AssetBucket.Other; }); // reload assets propagatedAssets = assets.ToDictionary(p => p.Key, _ => false, StringComparer.OrdinalIgnoreCase); updatedNpcWarps = false; foreach (var bucket in buckets) { switch (bucket.Key) { case AssetBucket.Sprite: if (!ignoreWorld) this.ReloadNpcSprites(bucket.Select(p => p.Key), propagatedAssets); break; case AssetBucket.Portrait: if (!ignoreWorld) this.ReloadNpcPortraits(bucket.Select(p => p.Key), propagatedAssets); break; default: foreach (var entry in bucket) { bool changed = false; bool curChangedMapWarps = false; try { changed = this.PropagateOther(entry.Key, entry.Value, ignoreWorld, out curChangedMapWarps); } catch (Exception ex) { this.Monitor.Log($"An error occurred while propagating asset changes. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } propagatedAssets[entry.Key] = changed; updatedNpcWarps = updatedNpcWarps || curChangedMapWarps; } break; } } // reload NPC pathfinding cache if any map changed if (updatedNpcWarps) NPC.populateRoutesFromLocationToLocationList(); } /********* ** Private methods *********/ /// 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. /// Whether any map warps were changed as part of this propagation. /// 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, bool ignoreWorld, out bool changedWarps) { var content = this.MainContentManager; key = this.AssertAndNormalizeAssetName(key); changedWarps = false; /**** ** Special case: current map tilesheet ** 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 (!ignoreWorld && Game1.currentLocation?.map?.TileSheets != null) { foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { if (this.NormalizeAssetNameIgnoringEmpty(tilesheet.ImageSource) == key) Game1.mapDisplayDevice.LoadTileSheet(tilesheet); } } /**** ** Propagate map changes ****/ if (type == typeof(Map)) { bool anyChanged = false; if (!ignoreWorld) { foreach (GameLocation location in this.GetLocations()) { if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) { static ISet GetWarpSet(GameLocation location) { return new HashSet( location.warps.Select(p => $"{p.X} {p.Y} {p.TargetName} {p.TargetX} {p.TargetY}") ); } var oldWarps = GetWarpSet(location); this.ReloadMap(location); var newWarps = GetWarpSet(location); changedWarps = changedWarps || oldWarps.Count != newWarps.Count || oldWarps.Any(p => !newWarps.Contains(p)); anyChanged = true; } } } return anyChanged; } /**** ** Propagate by key ****/ Reflector reflection = this.Reflection; switch (key.ToLower().Replace("/", "\\")) // normalized key so we can compare statically { /**** ** Animals ****/ case "animals\\horse": return !ignoreWorld && this.ReloadPetOrHorseSprites(content, key); /**** ** Buildings ****/ case "buildings\\houses": // Farm { var field = reflection.GetField(typeof(Farm), nameof(Farm.houseTextures)); field.SetValue( this.LoadAndDisposeIfNeeded(field.GetValue(), key) ); return true; } /**** ** Content\Characters\Farmer ****/ case "characters\\farmer\\accessories": // Game1.LoadContent FarmerRenderer.accessoriesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.accessoriesTexture, key); return true; case "characters\\farmer\\farmer_base": // Farmer case "characters\\farmer\\farmer_base_bald": case "characters\\farmer\\farmer_girl_base": case "characters\\farmer\\farmer_girl_base_bald": return !ignoreWorld && this.ReloadPlayerSprites(key); case "characters\\farmer\\hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hairStylesTexture, key); return true; case "characters\\farmer\\hats": // Game1.LoadContent FarmerRenderer.hatsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hatsTexture, key); return true; case "characters\\farmer\\pants": // Game1.LoadContent FarmerRenderer.pantsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.pantsTexture, key); return true; case "characters\\farmer\\shirts": // Game1.LoadContent FarmerRenderer.shirtsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.shirtsTexture, key); return true; /**** ** Content\Data ****/ case "data\\achievements": // Game1.LoadContent Game1.achievements = content.Load>(key); return true; case "data\\bigcraftablesinformation": // Game1.LoadContent Game1.bigCraftablesInformation = content.Load>(key); return true; case "data\\bundles": // NetWorldState constructor { var bundles = this.Reflection.GetField(Game1.netWorldState.Value, "bundles").GetValue(); var rewards = this.Reflection.GetField>(Game1.netWorldState.Value, "bundleRewards").GetValue(); foreach (var pair in content.Load>(key)) { int bundleKey = int.Parse(pair.Key.Split('/')[1]); int rewardsCount = pair.Value.Split('/')[2].Split(' ').Length; // add bundles if (!bundles.TryGetValue(bundleKey, out bool[] values) || values.Length < rewardsCount) { values ??= new bool[0]; bundles.Remove(bundleKey); bundles[bundleKey] = values.Concat(Enumerable.Repeat(false, rewardsCount - values.Length)).ToArray(); } // add bundle rewards if (!rewards.ContainsKey(bundleKey)) rewards[bundleKey] = false; } } break; case "data\\clothinginformation": // Game1.LoadContent Game1.clothingInformation = content.Load>(key); return true; case "data\\concessions": // MovieTheater.GetConcessions MovieTheater.ClearCachedLocalizedData(); return true; case "data\\concessiontastes": // MovieTheater.GetConcessionTasteForCharacter this.Reflection .GetField>(typeof(MovieTheater), "_concessionTastes") .SetValue(content.Load>(key)); return true; case "data\\cookingrecipes": // CraftingRecipe.InitShared CraftingRecipe.cookingRecipes = content.Load>(key); return true; case "data\\craftingrecipes": // CraftingRecipe.InitShared CraftingRecipe.craftingRecipes = content.Load>(key); return true; case "data\\farmanimals": // FarmAnimal constructor return !ignoreWorld && this.ReloadFarmAnimalData(); case "data\\hairdata": // Farmer.GetHairStyleMetadataFile return this.ReloadHairData(); case "data\\movies": // MovieTheater.GetMovieData case "data\\moviesreactions": // MovieTheater.GetMovieReactions MovieTheater.ClearCachedLocalizedData(); return true; case "data\\npcdispositions": // NPC constructor return !ignoreWorld && this.ReloadNpcDispositions(content, key); case "data\\npcgifttastes": // Game1.LoadContent Game1.NPCGiftTastes = content.Load>(key); return true; case "data\\objectcontexttags": // Game1.LoadContent Game1.objectContextTags = content.Load>(key); return true; case "data\\objectinformation": // Game1.LoadContent Game1.objectInformation = content.Load>(key); return true; /**** ** Content\Fonts ****/ case "fonts\\spritefont1": // Game1.LoadContent Game1.dialogueFont = content.Load(key); return true; case "fonts\\smallfont": // Game1.LoadContent Game1.smallFont = content.Load(key); return true; case "fonts\\tinyfont": // Game1.LoadContent Game1.tinyFont = content.Load(key); return true; case "fonts\\tinyfontborder": // Game1.LoadContent Game1.tinyFontBorder = content.Load(key); return true; /**** ** Content\LooseSprites\Lighting ****/ case "loosesprites\\lighting\\greenlight": // Game1.LoadContent Game1.cauldronLight = content.Load(key); return true; case "loosesprites\\lighting\\indoorwindowlight": // Game1.LoadContent Game1.indoorWindowLight = content.Load(key); return true; case "loosesprites\\lighting\\lantern": // Game1.LoadContent Game1.lantern = content.Load(key); return true; case "loosesprites\\lighting\\sconcelight": // Game1.LoadContent Game1.sconceLight = content.Load(key); return true; case "loosesprites\\lighting\\windowlight": // Game1.LoadContent Game1.windowLight = content.Load(key); return true; /**** ** Content\LooseSprites ****/ case "loosesprites\\birds": // Game1.LoadContent Game1.birdsSpriteSheet = content.Load(key); return true; case "loosesprites\\concessions": // Game1.LoadContent Game1.concessionsSpriteSheet = content.Load(key); return true; case "loosesprites\\controllermaps": // Game1.LoadContent Game1.controllerMaps = content.Load(key); return true; case "loosesprites\\cursors": // Game1.LoadContent Game1.mouseCursors = content.Load(key); foreach (DayTimeMoneyBox menu in Game1.onScreenMenus.OfType()) { foreach (ClickableTextureComponent button in new[] { menu.questButton, menu.zoomInButton, menu.zoomOutButton }) button.texture = Game1.mouseCursors; } if (!ignoreWorld) this.ReloadDoorSprites(content, key); return true; case "loosesprites\\cursors2": // Game1.LoadContent Game1.mouseCursors2 = content.Load(key); return true; case "loosesprites\\daybg": // Game1.LoadContent Game1.daybg = content.Load(key); return true; case "loosesprites\\font_bold": // Game1.LoadContent SpriteText.spriteTexture = content.Load(key); return true; case "loosesprites\\font_colored": // Game1.LoadContent SpriteText.coloredTexture = content.Load(key); return true; case "loosesprites\\nightbg": // Game1.LoadContent Game1.nightbg = content.Load(key); return true; case "loosesprites\\shadow": // Game1.LoadContent Game1.shadowTexture = content.Load(key); return true; case "loosesprites\\suspensionbridge": // SuspensionBridge constructor return !ignoreWorld && this.ReloadSuspensionBridges(content, key); /**** ** Content\Maps ****/ case "maps\\menutiles": // Game1.LoadContent Game1.menuTexture = content.Load(key); return true; case "maps\\menutilesuncolored": // Game1.LoadContent Game1.uncoloredMenuTexture = content.Load(key); return true; case "maps\\springobjects": // Game1.LoadContent Game1.objectSpriteSheet = content.Load(key); return true; case "maps\\walls_and_floors": // Wallpaper Wallpaper.wallpaperTexture = content.Load(key); return true; /**** ** Content\Minigames ****/ case "minigames\\clouds": // TitleMenu { if (Game1.activeClickableMenu is TitleMenu titleMenu) { titleMenu.cloudsTexture = content.Load(key); return true; } } return false; case "minigames\\titlebuttons": // TitleMenu return this.ReloadTitleButtons(content, key); /**** ** Content\Strings ****/ case "strings\\stringsfromcsfiles": return this.ReloadStringsFromCsFiles(content); /**** ** Content\TileSheets ****/ case "tilesheets\\animations": // Game1.LoadContent Game1.animations = content.Load(key); return true; case "tilesheets\\buffsicons": // Game1.LoadContent Game1.buffsIcons = content.Load(key); return true; case "tilesheets\\bushes": // new Bush() Bush.texture = new Lazy(() => content.Load(key)); return true; case "tilesheets\\chairtiles": // Game1.LoadContent return this.ReloadChairTiles(content, key, ignoreWorld); case "tilesheets\\craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load(key); return true; case "tilesheets\\critters": // Critter constructor return !ignoreWorld && this.ReloadCritterTextures(content, key) > 0; case "tilesheets\\crops": // Game1.LoadContent Game1.cropSpriteSheet = content.Load(key); return true; case "tilesheets\\debris": // Game1.LoadContent Game1.debrisSpriteSheet = content.Load(key); return true; case "tilesheets\\emotes": // Game1.LoadContent Game1.emoteSpriteSheet = content.Load(key); return true; case "tilesheets\\fruittrees": // FruitTree FruitTree.texture = content.Load(key); return true; case "tilesheets\\furniture": // Game1.LoadContent Furniture.furnitureTexture = content.Load(key); return true; case "tilesheets\\furniturefront": // Game1.LoadContent Furniture.furnitureFrontTexture = content.Load(key); return true; case "tilesheets\\projectiles": // Game1.LoadContent Projectile.projectileSheet = content.Load(key); return true; case "tilesheets\\rain": // Game1.LoadContent Game1.rainTexture = content.Load(key); return true; case "tilesheets\\tools": // Game1.ResetToolSpriteSheet Game1.ResetToolSpriteSheet(); return true; case "tilesheets\\weapons": // Game1.LoadContent Tool.weaponsTexture = content.Load(key); return true; /**** ** Content\TerrainFeatures ****/ case "terrainfeatures\\flooring": // from Flooring Flooring.floorsTexture = content.Load(key); return true; case "terrainfeatures\\flooring_winter": // from Flooring Flooring.floorsTextureWinter = content.Load(key); return true; case "terrainfeatures\\grass": // from Grass return !ignoreWorld && this.ReloadGrassTextures(content, key); case "terrainfeatures\\hoedirt": // from HoeDirt HoeDirt.lightTexture = content.Load(key); return true; case "terrainfeatures\\hoedirtdark": // from HoeDirt HoeDirt.darkTexture = content.Load(key); return true; case "terrainfeatures\\hoedirtsnow": // from HoeDirt HoeDirt.snowTexture = content.Load(key); return true; case "terrainfeatures\\mushroom_tree": // from Tree return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.mushroomTree); case "terrainfeatures\\tree_palm": // from Tree 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 !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 !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 !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.pineTree); } /**** ** Dynamic assets ****/ 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.KeyStartsWith(key, "LooseSprites\\Fence")) return this.ReloadFenceTextures(key); // dynamic data if (this.IsInFolder(key, "Characters\\Dialogue")) return this.ReloadNpcDialogue(key); if (this.IsInFolder(key, "Characters\\schedules")) return this.ReloadNpcSchedules(key); } return false; } /********* ** Private methods *********/ /**** ** Reload texture methods ****/ /// Reload buttons on the title screen. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. /// Derived from the constructor and . private bool ReloadTitleButtons(LocalizedContentManager content, string key) { if (Game1.activeClickableMenu is TitleMenu titleMenu) { Texture2D texture = content.Load(key); titleMenu.titleButtonsTexture = texture; titleMenu.backButton.texture = texture; titleMenu.aboutButton.texture = texture; titleMenu.languageButton.texture = texture; foreach (ClickableTextureComponent button in titleMenu.buttons) button.texture = texture; foreach (TemporaryAnimatedSprite bird in titleMenu.birds) bird.texture = texture; return true; } return false; } /// Reload the sprites for matching pets or horses. /// The animal type. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadPetOrHorseSprites(LocalizedContentManager content, string key) where TAnimal : NPC { // find matches TAnimal[] animals = this.GetCharacters() .OfType() .Where(p => key == this.NormalizeAssetNameIgnoringEmpty(p.Sprite?.Texture?.Name)) .ToArray(); if (!animals.Any()) return false; // update sprites Texture2D texture = content.Load(key); foreach (TAnimal animal in animals) animal.Sprite.spriteTexture = texture; return true; } /// Reload the sprites for matching farm animals. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. /// Derived from . private bool ReloadFarmAnimalSprites(LocalizedContentManager content, string key) { // find matches FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); if (!animals.Any()) return false; // update sprites Lazy texture = new Lazy(() => content.Load(key)); foreach (FarmAnimal animal in animals) { // get expected key string expectedKey = animal.age.Value < animal.ageWhenMature.Value ? $"Baby{(animal.type.Value == "Duck" ? "White Chicken" : animal.type.Value)}" : animal.type.Value; if (animal.showDifferentTextureWhenReadyForHarvest.Value && animal.currentProduce.Value <= 0) expectedKey = $"Sheared{expectedKey}"; expectedKey = $"Animals\\{expectedKey}"; // reload asset if (expectedKey == key) animal.Sprite.spriteTexture = texture.Value; } return texture.IsValueCreated; } /// Reload building textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadBuildings(LocalizedContentManager content, string key) { // get buildings string type = Path.GetFileName(key); Building[] buildings = this.GetLocations(buildingInteriors: false) .OfType() .SelectMany(p => p.buildings) .Where(p => p.buildingType.Value == type) .ToArray(); // reload buildings if (buildings.Any()) { Lazy texture = new Lazy(() => content.Load(key)); foreach (Building building in buildings) building.texture = texture; return true; } return false; } /// 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, bool ignoreWorld) { MapSeat.mapChairTexture = content.Load(key); if (!ignoreWorld) { foreach (var location in this.GetLocations()) { 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; } } } return true; } /// Reload critter textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns the number of reloaded assets. private int ReloadCritterTextures(LocalizedContentManager content, string key) { // get critters Critter[] critters = ( from location in this.GetLocations() where location.critters != null from Critter critter in location.critters where this.NormalizeAssetNameIgnoringEmpty(critter.sprite?.Texture?.Name) == key select critter ) .ToArray(); if (!critters.Any()) return 0; // update sprites Texture2D texture = content.Load(key); foreach (var entry in critters) entry.sprite.spriteTexture = texture; return critters.Length; } /// Reload the sprites for interior doors. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any doors were affected. private bool ReloadDoorSprites(LocalizedContentManager content, string key) { Lazy texture = new Lazy(() => content.Load(key)); foreach (GameLocation location in this.GetLocations()) { IEnumerable doors = location.interiorDoors?.Doors; if (doors == null) continue; foreach (InteriorDoor door in doors) { if (door?.Sprite == null) continue; string textureName = this.NormalizeAssetNameIgnoringEmpty(this.Reflection.GetField(door.Sprite, "textureName").GetValue()); if (textureName != key) continue; door.Sprite.texture = texture.Value; } } return texture.IsValueCreated; } /// Reload the data for matching farm animals. /// Returns whether any farm animals were affected. /// Derived from the constructor. private bool ReloadFarmAnimalData() { bool changed = false; foreach (FarmAnimal animal in this.GetFarmAnimals()) { animal.reloadData(); changed = true; } return changed; } /// Reload the sprites for a fence type. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadFenceTextures(string key) { // get fence type if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType)) return false; // get fences Fence[] fences = ( from location in this.GetLocations() from fence in location.Objects.Values.OfType() where fence.whichType.Value == fenceType || (fence.isGate.Value && fenceType == 1) // gates are hardcoded to draw fence type 1 select fence ) .ToArray(); // update fence textures foreach (Fence fence in fences) fence.fenceTexture = new Lazy(fence.loadFenceTexture); return true; } /// Reload tree textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadGrassTextures(LocalizedContentManager content, string key) { Grass[] grasses = ( from location in this.GetLocations() from grass in location.terrainFeatures.Values.OfType() where this.NormalizeAssetNameIgnoringEmpty(grass.textureName()) == key select grass ) .ToArray(); if (grasses.Any()) { Lazy texture = new Lazy(() => content.Load(key)); foreach (Grass grass in grasses) grass.texture = texture; return true; } return false; } /// Reload hair style metadata. /// Returns whether any assets were reloaded. /// Derived from the and . private bool ReloadHairData() { if (Farmer.hairStyleMetadataFile == null) return false; Farmer.hairStyleMetadataFile = null; Farmer.allHairStyleIndices = null; Farmer.hairStyleMetadata.Clear(); return true; } /// Reload the map for a location. /// The location whose map to reload. private void ReloadMap(GameLocation location) { if (this.AggressiveMemoryOptimizations) location.map.DisposeTileSheets(Game1.mapDisplayDevice); // reload map location.interiorDoors.Clear(); // prevent errors when doors try to update tiles which no longer exist location.reloadMap(); location.updateWarps(); location.MakeMapModifications(force: true); // update interior doors location.interiorDoors.Clear(); foreach (var entry in new InteriorDoorDictionary(location)) location.interiorDoors.Add(entry); // update doors location.doors.Clear(); location.updateDoors(); } /// Reload the disposition data for matching NPCs. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any NPCs were affected. private bool ReloadNpcDispositions(LocalizedContentManager content, string key) { IDictionary data = content.Load>(key); bool changed = false; foreach (NPC npc in this.GetCharacters()) { if (npc.isVillager() && data.ContainsKey(npc.Name)) { npc.reloadData(); changed = true; } } return changed; } /// Reload the sprites for matching NPCs. /// The asset keys to reload. /// The asset keys which have been propagated. private void ReloadNpcSprites(IEnumerable keys, IDictionary propagated) { // get NPCs HashSet lookup = new HashSet(keys, StringComparer.OrdinalIgnoreCase); var characters = ( from npc in this.GetCharacters() let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name) where key != null && lookup.Contains(key) select new { Npc = npc, Key = key } ) .ToArray(); if (!characters.Any()) return; // update sprite foreach (var target in characters) { target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.Key); propagated[target.Key] = true; } } /// Reload the portraits for matching NPCs. /// The asset key to reload. /// The asset keys which have been propagated. private void ReloadNpcPortraits(IEnumerable keys, IDictionary propagated) { // get NPCs HashSet lookup = new HashSet(keys, StringComparer.OrdinalIgnoreCase); var characters = ( from npc in this.GetCharacters() where npc.isVillager() let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name) where key != null && lookup.Contains(key) select new { Npc = npc, Key = key } ) .ToList(); // special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait) { string gilKey = this.NormalizeAssetNameIgnoringEmpty("Portraits/Gil"); if (lookup.Contains(gilKey)) { GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild"); if (adventureGuild != null) characters.Add(new { Npc = this.Reflection.GetField(adventureGuild, "Gil").GetValue(), Key = gilKey }); } } // update portrait foreach (var target in characters) { target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.Key); propagated[target.Key] = true; } } /// Reload the sprites for matching players. /// The asset key to reload. private bool ReloadPlayerSprites(string key) { Farmer[] players = ( from player in Game1.getOnlineFarmers() where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture()) select player ) .ToArray(); foreach (Farmer player in players) { this.Reflection.GetField>>>(typeof(FarmerRenderer), "_recolorOffsets").GetValue().Remove(player.getTexture()); player.FarmerRenderer.MarkSpriteDirty(); } return players.Any(); } /// Reload suspension bridge textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// Returns whether any textures were reloaded. private bool ReloadSuspensionBridges(LocalizedContentManager content, string key) { Lazy texture = new Lazy(() => content.Load(key)); foreach (GameLocation location in this.GetLocations(buildingInteriors: false)) { // get suspension bridges field var field = this.Reflection.GetField>(location, nameof(IslandNorth.suspensionBridges), required: false); if (field == null || !typeof(IEnumerable).IsAssignableFrom(field.FieldInfo.FieldType)) continue; // update textures foreach (SuspensionBridge bridge in field.GetValue()) this.Reflection.GetField(bridge, "_texture").SetValue(texture.Value); } return texture.IsValueCreated; } /// Reload tree textures. /// The content manager through which to reload the asset. /// The asset key to reload. /// The type to reload. /// Returns whether any textures were reloaded. private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) { Tree[] trees = this.GetLocations() .SelectMany(p => p.terrainFeatures.Values.OfType()) .Where(tree => tree.treeType.Value == type) .ToArray(); if (trees.Any()) { Lazy texture = new Lazy(() => content.Load(key)); foreach (Tree tree in trees) tree.texture = texture; return true; } return false; } /**** ** Reload data methods ****/ /// Reload the dialogue data for matching NPCs. /// The asset key to reload. /// Returns whether any assets were reloaded. private bool ReloadNpcDialogue(string key) { // get NPCs string name = Path.GetFileName(key); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; // update dialogue // Note that marriage dialogue isn't reloaded after reset, but it doesn't need to be // propagated anyway since marriage dialogue keys can't be added/removed and the field // doesn't store the text itself. foreach (NPC villager in villagers) { bool shouldSayMarriageDialogue = villager.shouldSayMarriageDialogue.Value; MarriageDialogueReference[] marriageDialogue = villager.currentMarriageDialogue.ToArray(); villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue villager.resetCurrentDialogue(); villager.shouldSayMarriageDialogue.Set(shouldSayMarriageDialogue); villager.currentMarriageDialogue.Set(marriageDialogue); } return true; } /// Reload the schedules for matching NPCs. /// The asset key to reload. /// Returns whether any assets were reloaded. private bool ReloadNpcSchedules(string key) { // get NPCs string name = Path.GetFileName(key); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; // update schedule foreach (NPC villager in villagers) { // reload schedule this.Reflection.GetField(villager, "_hasLoadedMasterScheduleData").SetValue(false); this.Reflection.GetField>(villager, "_masterScheduleData").SetValue(null); villager.Schedule = villager.getSchedule(Game1.dayOfMonth); // switch to new schedule if needed if (villager.Schedule != null) { int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault(); if (lastScheduleTime != 0) { villager.queuedSchedulePaths.Clear(); villager.lastAttemptedSchedule = 0; villager.checkSchedule(lastScheduleTime); } } } return true; } /// Reload cached translations from the Strings\StringsFromCSFiles asset. /// The content manager through which to reload the asset. /// Returns whether any data was reloaded. /// Derived from the . private bool ReloadStringsFromCsFiles(LocalizedContentManager content) { Game1.samBandName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2156"); Game1.elliottBookName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2157"); string[] dayNames = this.Reflection.GetField(typeof(Game1), "_shortDayDisplayName").GetValue(); dayNames[0] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3042"); dayNames[1] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3043"); dayNames[2] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3044"); dayNames[3] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3045"); dayNames[4] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3046"); dayNames[5] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3047"); dayNames[6] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3048"); return true; } /**** ** Helpers ****/ /// Get all NPCs in the game (excluding farm animals). private IEnumerable GetCharacters() { foreach (NPC character in this.GetLocations().SelectMany(p => p.characters)) yield return character; if (Game1.CurrentEvent?.actors != null) { foreach (NPC character in Game1.CurrentEvent.actors) yield return character; } } /// Get all farm animals in the game. private IEnumerable GetFarmAnimals() { foreach (GameLocation location in this.GetLocations()) { if (location is Farm farm) { foreach (FarmAnimal animal in farm.animals.Values) yield return animal; } else if (location is AnimalHouse animalHouse) foreach (FarmAnimal animal in animalHouse.animals.Values) yield return animal; } } /// Get all locations in the game. /// Whether to also get the interior locations for constructable buildings. private IEnumerable GetLocations(bool buildingInteriors = true) { // get available root locations IEnumerable rootLocations = Game1.locations; if (SaveGame.loaded?.locations != null) rootLocations = rootLocations.Concat(SaveGame.loaded.locations); // yield root + child locations foreach (GameLocation location in rootLocations) { yield return location; if (buildingInteriors && location is BuildableGameLocation buildableLocation) { foreach (Building building in buildableLocation.buildings) { GameLocation indoors = building.indoors.Value; if (indoors != null) yield return indoors; } } } } /// Normalize an asset key to match the cache key and assert that it's valid, but don't raise an error for null or empty values. /// The asset key to normalize. private string NormalizeAssetNameIgnoringEmpty(string path) { if (string.IsNullOrWhiteSpace(path)) return null; return this.AssertAndNormalizeAssetName(path); } /// Get whether a key starts with a substring after the substring is normalized. /// The key to check. /// The substring to normalize and find. private bool KeyStartsWith(string key, string rawSubstring) { if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(rawSubstring)) return false; return key.StartsWith(this.NormalizeAssetNameIgnoringEmpty(rawSubstring), StringComparison.OrdinalIgnoreCase); } /// Get whether a normalized asset key is in the given folder. /// The normalized asset key (like Animals/cat). /// The key folder (like Animals); doesn't need to be normalized. /// Whether to return true if the key is inside a subfolder of the . private bool IsInFolder(string key, string folder, bool allowSubfolders = false) { return this.KeyStartsWith(key, $"{folder}\\") && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1); } /// Get the segments in a path (e.g. 'a/b' is 'a' and 'b'). /// The path to check. private string[] GetSegments(string path) { return path != null ? PathUtilities.GetSegments(path) : new string[0]; } /// Count the number of segments in a path (e.g. 'a/b' is 2). /// The path to check. private int CountSegments(string path) { return this.GetSegments(path).Length; } /// Load a texture, and dispose the old one if is enabled and it's different from the new instance. /// The previous texture to dispose. /// The asset key to load. private Texture2D LoadAndDisposeIfNeeded(Texture2D oldTexture, string key) { // if aggressive memory optimizations are enabled, load the asset from the disposable // content manager and dispose the old instance if needed. if (this.AggressiveMemoryOptimizations) { GameContentManagerForAssetPropagation content = this.DisposableContentManager; Texture2D newTexture = content.Load(key); if (oldTexture?.IsDisposed == false && !object.ReferenceEquals(oldTexture, newTexture) && content.IsResponsibleFor(oldTexture)) oldTexture.Dispose(); return newTexture; } // else just (re)load it from the main content manager return this.MainContentManager.Load(key); } } }