diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/SMAPI/Framework/ContentCoordinator.cs | 16 | ||||
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/ModContentManager.cs | 185 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 215 |
3 files changed, 199 insertions, 217 deletions
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 15f1c163..8bb63299 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -101,9 +101,21 @@ namespace StardewModdingAPI.Framework /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param> - public ModContentManager CreateModContentManager(string name, string rootDirectory) + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> + public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager) { - ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.JsonHelper, this.OnDisposing); + ModContentManager manager = new ModContentManager( + name: name, + gameContentManager: gameContentManager, + serviceProvider: this.MainContentManager.ServiceProvider, + rootDirectory: rootDirectory, + currentCulture: this.MainContentManager.CurrentCulture, + coordinator: this, + monitor: this.Monitor, + reflection: this.Reflection, + jsonHelper: this.JsonHelper, + onDisposing: this.OnDisposing + ); this.ContentManagers.Add(manager); return manager; } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 1df7d107..45303f6b 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,12 +1,19 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using xTile; +using xTile.Format; +using xTile.ObjectModel; +using xTile.Tiles; namespace StardewModdingAPI.Framework.ContentManagers { @@ -19,12 +26,16 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <summary>The game content manager used for map tilesheets not provided by the mod.</summary> + private readonly IContentManager GameContentManager; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> /// <param name="currentCulture">The current culture for which to localise content.</param> @@ -33,9 +44,10 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) { + this.GameContentManager = gameContentManager; this.JsonHelper = jsonHelper; } @@ -47,11 +59,9 @@ namespace StardewModdingAPI.Framework.ContentManagers { assetName = this.AssertAndNormaliseAssetName(assetName); - // get from cache + // get managed asset if (this.IsLoaded(assetName)) return base.Load<T>(assetName, language); - - // get managed asset if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { if (contentManagerID != this.Name) @@ -64,7 +74,11 @@ namespace StardewModdingAPI.Framework.ContentManagers return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language); } - throw new NotSupportedException("Can't load content folder asset from a mod content manager."); + // get local asset + string internalKey = this.GetInternalAssetKey(assetName); + if (this.IsLoaded(internalKey)) + return base.Load<T>(internalKey, language); + return this.LoadManagedAsset<T>(internalKey, this.Name, assetName, this.Language); } /// <summary>Create a new content manager for temporary use.</summary> @@ -73,6 +87,16 @@ namespace StardewModdingAPI.Framework.ContentManagers throw new NotSupportedException("Can't create a temporary mod content manager."); } + /// <summary>Get the underlying key in the game's content cache for an asset. This does not validate whether the asset exists.</summary> + /// <param name="key">The local path to a content file relative to the mod folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetInternalAssetKey(string key) + { + FileInfo file = this.GetModFile(key); + string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName); + return Path.Combine(this.Name, relativePath); + } + /********* ** Private methods @@ -133,7 +157,18 @@ namespace StardewModdingAPI.Framework.ContentManagers // unpacked map case ".tbin": - throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.FixCustomTilesheetPaths(map, relativeMapPath: relativePath); + + // inject map + this.Inject(internalKey, map, this.Language); + return (T)(object)map; default: throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'."); @@ -186,5 +221,143 @@ namespace StardewModdingAPI.Framework.ContentManagers texture.SetData(data); return texture; } + + /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + /// <param name="relativeMapPath">The relative map path within the mod folder.</param> + /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> + /// <remarks> + /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils + /// down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded + /// as-is relative to the <c>Content</c> folder. + /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. + /// + /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. + /// Instead we use a more heuristic approach: check relative to the map file first, then relative to + /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a + /// seasonal variation and then an exact match. + /// + /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. + /// </remarks> + private void FixCustomTilesheetPaths(Map map, string relativeMapPath) + { + // get map info + if (!map.TileSheets.Any()) + return; + relativeMapPath = this.AssertAndNormaliseAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators + string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder + bool isOutdoors = map.Properties.TryGetValue("Outdoors", out PropertyValue outdoorsProperty) && outdoorsProperty != null; + + // fix tilesheets + foreach (TileSheet tilesheet in map.TileSheets) + { + string imageSource = tilesheet.ImageSource; + + // validate tilesheet path + if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (isOutdoors && Context.IsSaveLoaded && Game1.currentSeason != null) + { + string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); + bool hasSeasonalPrefix = + filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) + { + string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); + seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; + } + } + + // load best match + try + { + string key = + this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) + ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); + if (key != null) + { + tilesheet.ImageSource = key; + continue; + } + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + + // none found + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); + } + } + + /// <summary>Get the actual asset name for a tilesheet.</summary> + /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> + /// <param name="imageSource">The tilesheet image source to load.</param> + /// <returns>Returns the asset name.</returns> + /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> + private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; + + // check relative to map file + { + string localKey = Path.Combine(modRelativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + return this.GetInternalAssetKey(localKey); + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, candidateKey.Length - 4) + : candidateKey; + + try + { + this.GameContentManager.Load<Texture2D>(contentKey); + return contentKey; + } + catch + { + // ignore file-not-found errors + // TODO: while it's useful to suppress an asset-not-found error here to avoid + // confusion, this is a pretty naive approach. Even if the file doesn't exist, + // the file may have been loaded through an IAssetLoader which failed. So even + // if the content file doesn't exist, that doesn't mean the error here is a + // content-not-found error. Unfortunately XNA doesn't provide a good way to + // detect the error type. + if (this.GetContentFolderFileExists(contentKey)) + throw; + } + } + } + + // not found + return null; + } + + /// <summary>Get whether a file from the game's content folder exists.</summary> + /// <param name="key">The asset key.</param> + private bool GetContentFolderFileExists(string key) + { + // get file path + string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path).Exists; + } } } diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 5c4c3bca..2d65f1f6 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -9,12 +9,8 @@ using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using xTile; -using xTile.Format; -using xTile.ObjectModel; -using xTile.Tiles; namespace StardewModdingAPI.Framework.ModHelpers { @@ -31,10 +27,7 @@ namespace StardewModdingAPI.Framework.ModHelpers private readonly IContentManager GameContentManager; /// <summary>A content manager for this mod which manages files from the mod's folder.</summary> - private readonly IContentManager ModContentManager; - - /// <summary>The absolute path to the mod folder.</summary> - private readonly string ModFolderPath; + private readonly ModContentManager ModContentManager; /// <summary>The friendly mod name for use in errors.</summary> private readonly string ModName; @@ -79,8 +72,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { this.ContentCore = contentCore; this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content"); - this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), rootDirectory: modFolderPath); - this.ModFolderPath = modFolderPath; + this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), modFolderPath, this.GameContentManager); this.ModName = modName; this.Monitor = monitor; } @@ -93,8 +85,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> public T Load<T>(string key, ContentSource source = ContentSource.ModFolder) { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); - try { this.AssertAndNormaliseAssetName(key); @@ -104,38 +94,10 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.GameContentManager.Load<T>(key); case ContentSource.ModFolder: - // get file - FileInfo file = this.GetModFile(key); - if (!file.Exists) - throw GetContentError($"there's no matching file at path '{file.FullName}'."); - string internalKey = this.GetInternalModAssetKey(file); - - // try cache - if (this.ModContentManager.IsLoaded(internalKey)) - return this.ModContentManager.Load<T>(internalKey); - - // fix map tilesheets - if (file.Extension.ToLower() == ".tbin") - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - this.FixCustomTilesheetPaths(map, relativeMapPath: key); - - // inject map - this.ModContentManager.Inject(internalKey, map, this.CurrentLocaleConstant); - return (T)(object)map; - } - - // load through content manager - return this.ModContentManager.Load<T>(internalKey); + return this.ModContentManager.Load<T>(key); default: - throw GetContentError($"unknown content source '{source}'."); + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); } } catch (Exception ex) when (!(ex is SContentLoadException)) @@ -164,8 +126,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.GameContentManager.AssertAndNormaliseAssetName(key); case ContentSource.ModFolder: - FileInfo file = this.GetModFile(key); - return this.GetInternalModAssetKey(file); + return this.ModContentManager.GetInternalAssetKey(key); default: throw new NotSupportedException($"Unknown content source '{source}'."); @@ -201,6 +162,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentCore.InvalidateCache(predicate).Any(); } + /********* ** Private methods *********/ @@ -214,170 +176,5 @@ namespace StardewModdingAPI.Framework.ModHelpers if (Path.IsPathRooted(key)) throw new ArgumentException("The asset key must not be an absolute path."); } - - /// <summary>Get the internal key in the content cache for a mod asset.</summary> - /// <param name="modFile">The asset file.</param> - private string GetInternalModAssetKey(FileInfo modFile) - { - string relativePath = PathUtilities.GetRelativePath(this.ModFolderPath, modFile.FullName); - return Path.Combine(this.ModContentManager.Name, relativePath); - } - - /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> - /// <param name="map">The map whose tilesheets to fix.</param> - /// <param name="relativeMapPath">The relative map path within the mod folder.</param> - /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> - /// <remarks> - /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils - /// down to this: - /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded - /// as-is relative to the <c>Content</c> folder. - /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. - /// - /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. - /// Instead we use a more heuristic approach: check relative to the map file first, then relative to - /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a - /// seasonal variation and then an exact match. - /// - /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. - /// </remarks> - private void FixCustomTilesheetPaths(Map map, string relativeMapPath) - { - // get map info - if (!map.TileSheets.Any()) - return; - relativeMapPath = this.ModContentManager.AssertAndNormaliseAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators - string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder - bool isOutdoors = map.Properties.TryGetValue("Outdoors", out PropertyValue outdoorsProperty) && outdoorsProperty != null; - - // fix tilesheets - foreach (TileSheet tilesheet in map.TileSheets) - { - string imageSource = tilesheet.ImageSource; - - // validate tilesheet path - if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); - - // get seasonal name (if applicable) - string seasonalImageSource = null; - if (isOutdoors && Context.IsSaveLoaded && Game1.currentSeason != null) - { - string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); - bool hasSeasonalPrefix = - filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); - if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) - { - string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); - seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; - } - } - - // load best match - try - { - string key = - this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) - ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); - if (key != null) - { - tilesheet.ImageSource = key; - continue; - } - } - catch (Exception ex) - { - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); - } - - // none found - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); - } - } - - /// <summary>Get the actual asset name for a tilesheet.</summary> - /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> - /// <param name="imageSource">The tilesheet image source to load.</param> - /// <returns>Returns the asset name.</returns> - /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> - private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) - { - if (imageSource == null) - return null; - - // check relative to map file - { - string localKey = Path.Combine(modRelativeMapFolder, imageSource); - FileInfo localFile = this.GetModFile(localKey); - if (localFile.Exists) - return this.GetActualAssetKey(localKey); - } - - // check relative to content folder - { - foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) - { - string contentKey = candidateKey.EndsWith(".png") - ? candidateKey.Substring(0, candidateKey.Length - 4) - : candidateKey; - - try - { - this.Load<Texture2D>(contentKey, ContentSource.GameContent); - return contentKey; - } - catch - { - // ignore file-not-found errors - // TODO: while it's useful to suppress an asset-not-found error here to avoid - // confusion, this is a pretty naive approach. Even if the file doesn't exist, - // the file may have been loaded through an IAssetLoader which failed. So even - // if the content file doesn't exist, that doesn't mean the error here is a - // content-not-found error. Unfortunately XNA doesn't provide a good way to - // detect the error type. - if (this.GetContentFolderFile(contentKey).Exists) - throw; - } - } - } - - // not found - return null; - } - - /// <summary>Get a file from the mod folder.</summary> - /// <param name="path">The asset path relative to the mod folder.</param> - private FileInfo GetModFile(string path) - { - // try exact match - path = Path.Combine(this.ModFolderPath, this.ModContentManager.NormalisePathSeparators(path)); - FileInfo file = new FileInfo(path); - - // try with default extension - if (!file.Exists && file.Extension.ToLower() != ".xnb") - { - FileInfo result = new FileInfo(path + ".xnb"); - if (result.Exists) - file = result; - } - - return file; - } - - /// <summary>Get a file from the game's content folder.</summary> - /// <param name="key">The asset key.</param> - private FileInfo GetContentFolderFile(string key) - { - // get file path - string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); - if (!path.EndsWith(".xnb")) - path += ".xnb"; - - // get file - return new FileInfo(path); - } } } |