using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; 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.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; /// The multiplayer instance whose map cache to update. private readonly Multiplayer Multiplayer; /// Simplifies access to private game code. private readonly Reflector Reflection; /// Parse a raw asset name. private readonly Func ParseAssetName; /// Optimized bucket categories for batch reloading assets. private enum AssetBucket { /// NPC overworld sprites. Sprite, /// Villager dialogue portraits. Portrait, /// Any other asset. Other }; /// A cache of world data fetched for the current tick. private readonly TickCacheDictionary WorldCache = new(); /********* ** 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. /// The multiplayer instance whose map cache to update. /// Simplifies access to private code. /// Parse a raw asset name. public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Multiplayer multiplayer, Reflector reflection, Func parseAssetName) { this.MainContentManager = mainContent; this.DisposableContentManager = disposableContent; this.Monitor = monitor; this.Multiplayer = multiplayer; this.Reflection = reflection; this.ParseAssetName = parseAssetName; } /// 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 warp route cache was reloaded. public void Propagate(IDictionary assets, bool ignoreWorld, out IDictionary propagatedAssets, out bool changedWarpRoutes) { // get base name lookup propagatedAssets = assets .Select(asset => asset.Key.GetBaseAssetName()) .Distinct() .ToDictionary(name => name, _ => false); // group into optimized lists var buckets = assets.GroupBy(p => { if (p.Key.IsDirectlyUnderPath("Characters") || p.Key.IsDirectlyUnderPath("Characters/Monsters")) return AssetBucket.Sprite; if (p.Key.IsDirectlyUnderPath("Portraits")) return AssetBucket.Portrait; return AssetBucket.Other; }); // reload assets changedWarpRoutes = false; foreach (var bucket in buckets) { switch (bucket.Key) { case AssetBucket.Sprite: if (!ignoreWorld) this.UpdateNpcSprites(propagatedAssets); break; case AssetBucket.Portrait: if (!ignoreWorld) this.UpdateNpcPortraits(propagatedAssets); break; default: foreach (var entry in bucket) { bool changed = false; bool curChangedMapRoutes = false; try { changed = this.PropagateOther(entry.Key, entry.Value, ignoreWorld, out curChangedMapRoutes); } catch (Exception ex) { this.Monitor.Log($"An error occurred while propagating asset changes. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } propagatedAssets[entry.Key] = changed; changedWarpRoutes = changedWarpRoutes || curChangedMapRoutes; } break; } } // reload NPC pathfinding cache if any map routes changed if (changedWarpRoutes) NPC.populateRoutesFromLocationToLocationList(); } /********* ** Private methods *********/ /// Reload one of the game's core assets (if applicable). /// The asset name 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 the locations reachable by warps from this location 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(IAssetName assetName, Type type, bool ignoreWorld, out bool changedWarpRoutes) { var content = this.MainContentManager; string key = assetName.BaseName; changedWarpRoutes = false; bool changed = 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.IsSameBaseName(assetName, tilesheet.ImageSource)) { Game1.mapDisplayDevice.LoadTileSheet(tilesheet); changed = true; } } } /**** ** Propagate map changes ****/ if (type == typeof(Map)) { if (!ignoreWorld) { foreach (LocationInfo info in this.GetLocationsWithInfo()) { GameLocation location = info.Location; if (this.IsSameBaseName(assetName, location.mapPath.Value)) { static ISet GetWarpSet(GameLocation location) { return new HashSet( location.warps.Select(p => p.TargetName) ); } var oldWarps = GetWarpSet(location); this.UpdateMap(info); var newWarps = GetWarpSet(location); changedWarpRoutes = changedWarpRoutes || oldWarps.Count != newWarps.Count || oldWarps.Any(p => !newWarps.Contains(p)); changed = true; } } } return changed; } /**** ** Propagate by key ****/ switch (assetName.BaseName.ToLower().Replace("\\", "/")) // normalized key so we can compare statically { /**** ** Animals ****/ case "animals/horse": return changed | (!ignoreWorld && this.UpdatePetOrHorseSprites(assetName)); /**** ** Buildings ****/ case "buildings/houses": // Farm Farm.houseTextures = this.LoadTexture(key); return true; case "buildings/houses_paintmask": // Farm { bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); Farm farm = Game1.getFarm(); farm?.ApplyHousePaint(); return changed | (removedFromCache || farm != null); } /**** ** Content\Characters\Farmer ****/ case "characters/farmer/accessories": // Game1.LoadContent FarmerRenderer.accessoriesTexture = this.LoadTexture(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 changed | (!ignoreWorld && this.UpdatePlayerSprites(assetName)); case "characters/farmer/hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = this.LoadTexture(key); return true; case "characters/farmer/hats": // Game1.LoadContent FarmerRenderer.hatsTexture = this.LoadTexture(key); return true; case "characters/farmer/pants": // Game1.LoadContent FarmerRenderer.pantsTexture = this.LoadTexture(key); return true; case "characters/farmer/shirts": // Game1.LoadContent FarmerRenderer.shirtsTexture = this.LoadTexture(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/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 changed | (!ignoreWorld && this.UpdateFarmAnimalData()); case "data/hairdata": // Farmer.GetHairStyleMetadataFile return changed | this.UpdateHairData(); case "data/movies": // MovieTheater.GetMovieData case "data/moviesreactions": // MovieTheater.GetMovieReactions MovieTheater.ClearCachedLocalizedData(); return true; case "data/npcdispositions": // NPC constructor return changed | (!ignoreWorld && this.UpdateNpcDispositions(content, assetName)); 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.UpdateDoorSprites(content, assetName); 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/giftbox": // Game1.LoadContent Game1.giftboxTexture = 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 changed | (!ignoreWorld && this.UpdateSuspensionBridges(content, assetName)); /**** ** 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; /**** ** Content\Minigames ****/ case "minigames/clouds": // TitleMenu { if (Game1.activeClickableMenu is TitleMenu titleMenu) { titleMenu.cloudsTexture = content.Load(key); return true; } } return changed; case "minigames/titlebuttons": // TitleMenu return changed | this.UpdateTitleButtons(content, assetName); /**** ** Content\Strings ****/ case "strings/stringsfromcsfiles": return changed | this.UpdateStringsFromCsFiles(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.UpdateChairTiles(content, assetName, ignoreWorld); case "tilesheets/craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load(key); return true; case "tilesheets/critters": // Critter constructor return changed | (!ignoreWorld && this.UpdateCritterTextures(assetName)); 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.UpdateGrassTextures(content, assetName); 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 changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.mushroomTree)); case "terrainfeatures/tree_palm": // from Tree return changed | (!ignoreWorld && this.UpdateTreeTextures(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 changed | (!ignoreWorld && this.UpdateTreeTextures(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 changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.leafyTree)); case "terrainfeatures/tree3_fall": // from Tree case "terrainfeatures/tree3_spring": // from Tree case "terrainfeatures/tree3_winter": // from Tree return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.pineTree)); } /**** ** Dynamic assets ****/ if (!ignoreWorld) { // dynamic textures if (assetName.IsDirectlyUnderPath("Animals")) { if (assetName.StartsWith("animals/cat")) return changed | this.UpdatePetOrHorseSprites(assetName); if (assetName.StartsWith("animals/dog")) return changed | this.UpdatePetOrHorseSprites(assetName); return changed | this.UpdateFarmAnimalSprites(assetName); } if (assetName.IsDirectlyUnderPath("Buildings")) return changed | this.UpdateBuildings(assetName); if (assetName.StartsWith("LooseSprites/Fence")) return changed | this.UpdateFenceTextures(assetName); // dynamic data if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) return changed | this.UpdateNpcDialogue(assetName); if (assetName.IsDirectlyUnderPath("Characters/schedules")) return changed | this.UpdateNpcSchedules(assetName); } return false; } /********* ** Private methods *********/ /**** ** Update texture methods ****/ /// Update buttons on the title screen. /// The content manager through which to update the asset. /// The asset name to update. /// Returns whether any references were updated. /// Derived from the constructor and . private bool UpdateTitleButtons(LocalizedContentManager content, IAssetName assetName) { if (Game1.activeClickableMenu is TitleMenu titleMenu) { Texture2D texture = content.Load(assetName.BaseName); 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; } /// Update the sprites for matching pets or horses. /// The animal type. /// The asset name to update. /// Returns whether any references were updated. private bool UpdatePetOrHorseSprites(IAssetName assetName) where TAnimal : NPC { // find matches TAnimal[] animals = this.GetCharacters() .OfType() .Where(p => this.IsSameBaseName(assetName, p.Sprite?.spriteTexture?.Name)) .ToArray(); // update sprites bool changed = false; foreach (TAnimal animal in animals) changed |= this.MarkSpriteDirty(animal.Sprite); return changed; } /// Update the sprites for matching farm animals. /// The asset name to update. /// Returns whether any references were updated. /// Derived from . private bool UpdateFarmAnimalSprites(IAssetName assetName) { // find matches FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); if (!animals.Any()) return false; // update sprites bool changed = true; 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 (this.IsSameBaseName(assetName, expectedKey)) changed |= this.MarkSpriteDirty(animal.Sprite); } return changed; } /// Update building textures. /// The asset name to update. /// Returns whether any references were updated. private bool UpdateBuildings(IAssetName assetName) { // get paint mask info const string paintMaskSuffix = "_PaintMask"; bool isPaintMask = assetName.BaseName.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); // get building type string type = Path.GetFileName(assetName.BaseName); if (isPaintMask) type = type[..^paintMaskSuffix.Length]; // get buildings Building[] buildings = this.GetLocations(buildingInteriors: false) .OfType() .SelectMany(p => p.buildings) .Where(p => p.buildingType.Value == type) .ToArray(); // remove from paint mask cache bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); // reload textures if (buildings.Any()) { foreach (Building building in buildings) building.resetTexture(); return true; } return removedFromCache; } /// Update map seat textures. /// The content manager through which to reload the asset. /// The asset name to update. /// 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 references were updated. private bool UpdateChairTiles(LocalizedContentManager content, IAssetName assetName, bool ignoreWorld) { MapSeat.mapChairTexture = content.Load(assetName.BaseName); if (!ignoreWorld) { foreach (GameLocation location in this.GetLocations()) { foreach (MapSeat seat in location.mapSeats.Where(p => p != null)) { if (this.IsSameBaseName(assetName, seat._loadedTextureFile)) seat._loadedTextureFile = null; } } } return true; } /// Update critter textures. /// The asset name to update. /// Returns whether any references were updated. private bool UpdateCritterTextures(IAssetName assetName) { // get critters Critter[] critters = ( from location in this.GetLocations() where location.critters != null from Critter critter in location.critters where this.IsSameBaseName(assetName, critter.sprite?.spriteTexture?.Name) select critter ) .ToArray(); // update sprites bool changed = false; foreach (Critter entry in critters) changed |= this.MarkSpriteDirty(entry.sprite); return changed; } /// Update the sprites for interior doors. /// The content manager through which to reload the asset. /// The asset name to update. /// Returns whether any references were updated. private void UpdateDoorSprites(LocalizedContentManager content, IAssetName assetName) { Lazy texture = new Lazy(() => content.Load(assetName.BaseName)); 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? curKey = this.Reflection.GetField(door.Sprite, "textureName").GetValue(); if (this.IsSameBaseName(assetName, curKey)) door.Sprite.texture = texture.Value; } } } /// Update the sprites for a fence type. /// The asset name to update. /// Returns whether any references were updated. private bool UpdateFenceTextures(IAssetName assetName) { // get fence type (e.g. LooseSprites/Fence3 => 3) if (!int.TryParse(this.GetSegments(assetName.BaseName)[1]["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 bool changed = false; foreach (Fence fence in fences) { if (fence.fenceTexture.IsValueCreated) { fence.fenceTexture = new Lazy(fence.loadFenceTexture); changed = true; } } return changed; } /// Update tree textures. /// The content manager through which to reload the asset. /// The asset name to update. /// Returns whether any references were updated. private bool UpdateGrassTextures(LocalizedContentManager content, IAssetName assetName) { Grass[] grasses = ( from grass in this.GetTerrainFeatures().OfType() where this.IsSameBaseName(assetName, grass.textureName()) select grass ) .ToArray(); bool changed = false; foreach (Grass grass in grasses) { if (grass.texture.IsValueCreated) { grass.texture = new Lazy(() => content.Load(assetName.BaseName)); changed = true; } } return changed; } /// Update the sprites for matching NPCs. /// The asset names being propagated. private void UpdateNpcSprites(IDictionary propagated) { // get NPCs var characters = ( from npc in this.GetCharacters() let key = this.ParseAssetNameOrNull(npc.Sprite?.spriteTexture?.Name)?.GetBaseAssetName() where key != null && propagated.ContainsKey(key) select new { Npc = npc, AssetName = key } ) .ToArray(); // update sprite foreach (var target in characters) { if (this.MarkSpriteDirty(target.Npc.Sprite)) propagated[target.AssetName] = true; } } /// Update the portraits for matching NPCs. /// The asset names being propagated. private void UpdateNpcPortraits(IDictionary propagated) { // get NPCs var characters = ( from npc in this.GetCharacters() where npc.isVillager() let key = this.ParseAssetNameOrNull(npc.Portrait?.Name)?.GetBaseAssetName() where key != null && propagated.ContainsKey(key) select new { Npc = npc, AssetName = key } ) .ToList(); // special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait) { IAssetName gilKey = this.ParseAssetName("Portraits/Gil"); if (propagated.ContainsKey(gilKey)) { GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild"); if (adventureGuild != null) { NPC? gil = this.Reflection.GetField(adventureGuild, "Gil").GetValue(); if (gil != null) characters.Add(new { Npc = gil, AssetName = gilKey }); } } } // update portrait foreach (var target in characters) { target.Npc.resetPortrait(); propagated[target.AssetName] = true; } } /// Update the sprites for matching players. /// The asset name to update. private bool UpdatePlayerSprites(IAssetName assetName) { Farmer[] players = ( from player in Game1.getOnlineFarmers() where this.IsSameBaseName(assetName, player.getTexture()) select player ) .ToArray(); foreach (Farmer player in players) { var recolorOffsets = this.Reflection.GetField>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue(); recolorOffsets?.Clear(); player.FarmerRenderer.MarkSpriteDirty(); } return players.Any(); } /// Update suspension bridge textures. /// The content manager through which to reload the asset. /// The asset name to update. /// Returns whether any references were updated. private bool UpdateSuspensionBridges(LocalizedContentManager content, IAssetName assetName) { Lazy texture = new Lazy(() => content.Load(assetName.BaseName)); foreach (GameLocation location in this.GetLocations(buildingInteriors: false)) { // get suspension bridges field var field = this.Reflection.GetField?>(location, nameof(IslandNorth.suspensionBridges), required: false); // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- field is nullable when required: false if (field == null || !typeof(IEnumerable).IsAssignableFrom(field.FieldInfo.FieldType)) continue; // update textures IEnumerable? bridges = field.GetValue(); if (bridges != null) { foreach (SuspensionBridge bridge in bridges) this.Reflection.GetField(bridge, "_texture").SetValue(texture.Value); } } return texture.IsValueCreated; } /// Update tree textures. /// The type to update. /// Returns whether any references were updated. private bool UpdateTreeTextures(int type) { Tree[] trees = this .GetTerrainFeatures() .OfType() .Where(tree => tree.treeType.Value == type) .ToArray(); bool changed = false; foreach (Tree tree in trees) { if (tree.texture.IsValueCreated) { this.Reflection.GetMethod(tree, "resetTexture").Invoke(); changed = true; } } return changed; } /// Mark an animated sprite's texture dirty, so it's reloaded next time it's rendered. /// The animated sprite to change. /// Returns whether the sprite was changed. private bool MarkSpriteDirty(AnimatedSprite sprite) { if (sprite.loadedTexture is null && sprite.spriteTexture is null) return false; sprite.loadedTexture = null; sprite.spriteTexture = null; return true; } /**** ** Update data methods ****/ /// Update the data for matching farm animals. /// Returns whether any farm animals were updated. /// Derived from the constructor. private bool UpdateFarmAnimalData() { bool changed = false; foreach (FarmAnimal animal in this.GetFarmAnimals()) { animal.reloadData(); changed = true; } return changed; } /// Update hair style metadata. /// Returns whether any data was updated. /// Derived from the and . private bool UpdateHairData() { if (Farmer.hairStyleMetadataFile == null) return false; Farmer.hairStyleMetadataFile = null; Farmer.allHairStyleIndices = null; Farmer.hairStyleMetadata.Clear(); return true; } /// Update the dialogue data for matching NPCs. /// The asset name to update. /// Returns whether any NPCs were updated. private bool UpdateNpcDialogue(IAssetName assetName) { // get NPCs string name = Path.GetFileName(assetName.BaseName); 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; } /// Update the disposition data for matching NPCs. /// The content manager through which to reload the asset. /// The asset name to update. /// Returns whether any NPCs were updated. private bool UpdateNpcDispositions(LocalizedContentManager content, IAssetName assetName) { IDictionary data = content.Load>(assetName.BaseName); bool changed = false; foreach (NPC npc in this.GetCharacters()) { if (npc.isVillager() && data.ContainsKey(npc.Name)) { npc.reloadData(); changed = true; } } return changed; } /// Update the schedules for matching NPCs. /// The asset name to update. /// Returns whether any NPCs were updated. private bool UpdateNpcSchedules(IAssetName assetName) { // get NPCs string name = Path.GetFileName(assetName.BaseName); 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; } /// Update cached translations from the Strings\StringsFromCSFiles asset. /// The content manager through which to reload the asset. /// Returns whether any data was updated. /// Derived from the . private bool UpdateStringsFromCsFiles(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; } /**** ** Update map methods ****/ /// Update the map for a location. /// The location whose map to update. private void UpdateMap(LocationInfo locationInfo) { GameLocation location = locationInfo.Location; Vector2? playerPos = Game1.player?.Position; // remove from multiplayer cache this.Multiplayer.cachedMultiplayerMaps.Remove(location.NameOrUniqueName); // reload map location.interiorDoors.Clear(); // prevent errors when doors try to update tiles which no longer exist location.reloadMap(); // reload interior doors location.interiorDoors.Clear(); location.interiorDoors.ResetSharedState(); // load doors from map properties location.interiorDoors.ResetLocalState(); // reapply door tiles // reapply map changes (after reloading doors so they apply theirs too) location.MakeMapModifications(force: true); // update for changes location.updateWarps(); location.updateDoors(); locationInfo.ParentBuilding?.updateInteriorWarps(); // reset player position // The game may move the player as part of the map changes, even if they're not in that // location. That's not needed in this case, and it can have weird effects like players // warping onto the wrong tile (or even off-screen) if a patch changes the farmhouse // map on location change. if (playerPos.HasValue) Game1.player!.Position = playerPos.Value; } /**** ** Helpers ****/ /// Get all NPCs in the game (excluding farm animals). private IEnumerable GetCharacters() { return this.WorldCache.GetOrSet( nameof(this.GetCharacters), () => { List characters = new(); foreach (NPC character in this.GetLocations().SelectMany(p => p.characters)) characters.Add(character); if (Game1.CurrentEvent?.actors != null) { foreach (NPC character in Game1.CurrentEvent.actors) characters.Add(character); } return characters; } ); } /// Get all farm animals in the game. private IEnumerable GetFarmAnimals() { return this.WorldCache.GetOrSet( nameof(this.GetFarmAnimals), () => { List animals = new(); foreach (GameLocation location in this.GetLocations()) { if (location is Farm farm) { foreach (FarmAnimal animal in farm.animals.Values) animals.Add(animal); } else if (location is AnimalHouse animalHouse) { foreach (FarmAnimal animal in animalHouse.animals.Values) animals.Add(animal); } } return animals; } ); } /// Get all locations in the game. /// Whether to also get the interior locations for constructable buildings. private IEnumerable GetLocations(bool buildingInteriors = true) { return this.WorldCache.GetOrSet( $"{nameof(this.GetLocations)}_{buildingInteriors}", () => this.GetLocationsWithInfo(buildingInteriors).Select(info => info.Location).ToArray() ); } /// Get all locations in the game. /// Whether to also get the interior locations for constructable buildings. private IEnumerable GetLocationsWithInfo(bool buildingInteriors = true) { return this.WorldCache.GetOrSet( $"{nameof(this.GetLocationsWithInfo)}_{buildingInteriors}", () => { List locations = new(); // get root locations foreach (GameLocation location in Game1.locations) locations.Add(new LocationInfo(location, null)); if (SaveGame.loaded?.locations != null) { foreach (GameLocation location in SaveGame.loaded.locations) locations.Add(new LocationInfo(location, null)); } // get child locations if (buildingInteriors) { foreach (BuildableGameLocation location in locations.Select(p => p.Location).OfType().ToArray()) { foreach (Building building in location.buildings) { GameLocation indoors = building.indoors.Value; if (indoors is not null) locations.Add(new LocationInfo(indoors, building)); } } } return locations; }); } /// Get all terrain features in the game. private IEnumerable GetTerrainFeatures() { return this.WorldCache.GetOrSet( $"{nameof(this.GetTerrainFeatures)}", () => this.GetLocations().SelectMany(p => p.terrainFeatures.Values).ToArray() ); } /// Get whether two asset names are equivalent if you ignore the locale code. /// The first value to compare. /// The second value to compare. private bool IsSameBaseName(IAssetName? left, string? right) { if (left is null || right is null) return false; IAssetName? parsedB = this.ParseAssetNameOrNull(right); return this.IsSameBaseName(left, parsedB); } /// Get whether two asset names are equivalent if you ignore the locale code. /// The first value to compare. /// The second value to compare. private bool IsSameBaseName(IAssetName? left, IAssetName? right) { if (left is null || right is null) return false; return left.IsEquivalentTo(right.BaseName, useBaseName: true); } /// 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 IAssetName? ParseAssetNameOrNull(string? path) { if (string.IsNullOrWhiteSpace(path)) return null; return this.ParseAssetName(path); } /// 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) : Array.Empty(); } /// Load a texture from the main content manager. /// The asset key to load. private Texture2D LoadTexture(string key) { return this.MainContentManager.Load(key); } /// Remove a case-insensitive key from the paint mask cache. /// The paint mask asset name. private bool RemoveFromPaintMaskCache(IAssetName assetName) { // make cache case-insensitive // This is needed for cache invalidation since mods may specify keys with a different capitalization if (!object.ReferenceEquals(BuildingPainter.paintMaskLookup.Comparer, StringComparer.OrdinalIgnoreCase)) BuildingPainter.paintMaskLookup = new Dictionary>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase); // remove key from cache return BuildingPainter.paintMaskLookup.Remove(assetName.BaseName); } /// Metadata about a location used in asset propagation. /// The location instance. /// The building which contains the location, if any. private record LocationInfo(GameLocation Location, Building? ParentBuilding); } }