move most mod asset loading logic into content managers (#644)
This fixes mods needing to load Map assets manually before the game could load them via internal key.
This commit is contained in:
parent
bf3738eacb
commit
fff5e8c939
|
@ -11,6 +11,7 @@ These changes have not been released yet.
|
||||||
* Now ignores content files like `.txt` or `.png`, which avoids missing-manifest errors in some common cases.
|
* Now ignores content files like `.txt` or `.png`, which avoids missing-manifest errors in some common cases.
|
||||||
* Now detects XNB mods more accurately, and consolidates multi-folder XNB mods.
|
* Now detects XNB mods more accurately, and consolidates multi-folder XNB mods.
|
||||||
* Updated mod compatibility list.
|
* Updated mod compatibility list.
|
||||||
|
* Fixed mods needing to load custom `Map` assets before the game accesses them (SMAPI will now do so automatically).
|
||||||
* Fixed Save Backup not pruning old backups if they're uncompressed.
|
* Fixed Save Backup not pruning old backups if they're uncompressed.
|
||||||
* Fixed issues when a farmhand reconnects before the game notices they're disconnected.
|
* Fixed issues when a farmhand reconnects before the game notices they're disconnected.
|
||||||
* Fixed 'received message' logs shown in non-developer mode.
|
* Fixed 'received message' logs shown in non-developer mode.
|
||||||
|
|
|
@ -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>
|
/// <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="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>
|
/// <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);
|
this.ContentManagers.Add(manager);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,19 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Content;
|
||||||
using Microsoft.Xna.Framework.Graphics;
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
using StardewModdingAPI.Framework.Exceptions;
|
using StardewModdingAPI.Framework.Exceptions;
|
||||||
using StardewModdingAPI.Framework.Reflection;
|
using StardewModdingAPI.Framework.Reflection;
|
||||||
using StardewModdingAPI.Toolkit.Serialisation;
|
using StardewModdingAPI.Toolkit.Serialisation;
|
||||||
|
using StardewModdingAPI.Toolkit.Utilities;
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
|
using xTile;
|
||||||
|
using xTile.Format;
|
||||||
|
using xTile.ObjectModel;
|
||||||
|
using xTile.Tiles;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Framework.ContentManagers
|
namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
{
|
{
|
||||||
|
@ -19,12 +26,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
|
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
|
||||||
private readonly JsonHelper JsonHelper;
|
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
|
** Public methods
|
||||||
*********/
|
*********/
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
|
/// <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="serviceProvider">The service provider to use to locate services.</param>
|
||||||
/// <param name="rootDirectory">The root directory to search for content.</param>
|
/// <param name="rootDirectory">The root directory to search for content.</param>
|
||||||
/// <param name="currentCulture">The current culture for which to localise 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="reflection">Simplifies access to private code.</param>
|
||||||
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</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>
|
/// <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)
|
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true)
|
||||||
{
|
{
|
||||||
|
this.GameContentManager = gameContentManager;
|
||||||
this.JsonHelper = jsonHelper;
|
this.JsonHelper = jsonHelper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,11 +59,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
{
|
{
|
||||||
assetName = this.AssertAndNormaliseAssetName(assetName);
|
assetName = this.AssertAndNormaliseAssetName(assetName);
|
||||||
|
|
||||||
// get from cache
|
// get managed asset
|
||||||
if (this.IsLoaded(assetName))
|
if (this.IsLoaded(assetName))
|
||||||
return base.Load<T>(assetName, language);
|
return base.Load<T>(assetName, language);
|
||||||
|
|
||||||
// get managed asset
|
|
||||||
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
|
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
|
||||||
{
|
{
|
||||||
if (contentManagerID != this.Name)
|
if (contentManagerID != this.Name)
|
||||||
|
@ -64,7 +74,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language);
|
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>
|
/// <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.");
|
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
|
** Private methods
|
||||||
|
@ -133,7 +157,18 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
|
|
||||||
// unpacked map
|
// unpacked map
|
||||||
case ".tbin":
|
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:
|
default:
|
||||||
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'.");
|
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);
|
texture.SetData(data);
|
||||||
return texture;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,12 +9,8 @@ using Microsoft.Xna.Framework.Content;
|
||||||
using Microsoft.Xna.Framework.Graphics;
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
using StardewModdingAPI.Framework.ContentManagers;
|
using StardewModdingAPI.Framework.ContentManagers;
|
||||||
using StardewModdingAPI.Framework.Exceptions;
|
using StardewModdingAPI.Framework.Exceptions;
|
||||||
using StardewModdingAPI.Toolkit.Utilities;
|
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
using xTile;
|
using xTile;
|
||||||
using xTile.Format;
|
|
||||||
using xTile.ObjectModel;
|
|
||||||
using xTile.Tiles;
|
|
||||||
|
|
||||||
namespace StardewModdingAPI.Framework.ModHelpers
|
namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
{
|
{
|
||||||
|
@ -31,10 +27,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
private readonly IContentManager GameContentManager;
|
private readonly IContentManager GameContentManager;
|
||||||
|
|
||||||
/// <summary>A content manager for this mod which manages files from the mod's folder.</summary>
|
/// <summary>A content manager for this mod which manages files from the mod's folder.</summary>
|
||||||
private readonly IContentManager ModContentManager;
|
private readonly ModContentManager ModContentManager;
|
||||||
|
|
||||||
/// <summary>The absolute path to the mod folder.</summary>
|
|
||||||
private readonly string ModFolderPath;
|
|
||||||
|
|
||||||
/// <summary>The friendly mod name for use in errors.</summary>
|
/// <summary>The friendly mod name for use in errors.</summary>
|
||||||
private readonly string ModName;
|
private readonly string ModName;
|
||||||
|
@ -79,8 +72,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
{
|
{
|
||||||
this.ContentCore = contentCore;
|
this.ContentCore = contentCore;
|
||||||
this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content");
|
this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content");
|
||||||
this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), rootDirectory: modFolderPath);
|
this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), modFolderPath, this.GameContentManager);
|
||||||
this.ModFolderPath = modFolderPath;
|
|
||||||
this.ModName = modName;
|
this.ModName = modName;
|
||||||
this.Monitor = monitor;
|
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>
|
/// <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)
|
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
|
try
|
||||||
{
|
{
|
||||||
this.AssertAndNormaliseAssetName(key);
|
this.AssertAndNormaliseAssetName(key);
|
||||||
|
@ -104,38 +94,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
return this.GameContentManager.Load<T>(key);
|
return this.GameContentManager.Load<T>(key);
|
||||||
|
|
||||||
case ContentSource.ModFolder:
|
case ContentSource.ModFolder:
|
||||||
// get file
|
return this.ModContentManager.Load<T>(key);
|
||||||
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);
|
|
||||||
|
|
||||||
default:
|
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))
|
catch (Exception ex) when (!(ex is SContentLoadException))
|
||||||
|
@ -164,8 +126,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
return this.GameContentManager.AssertAndNormaliseAssetName(key);
|
return this.GameContentManager.AssertAndNormaliseAssetName(key);
|
||||||
|
|
||||||
case ContentSource.ModFolder:
|
case ContentSource.ModFolder:
|
||||||
FileInfo file = this.GetModFile(key);
|
return this.ModContentManager.GetInternalAssetKey(key);
|
||||||
return this.GetInternalModAssetKey(file);
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new NotSupportedException($"Unknown content source '{source}'.");
|
throw new NotSupportedException($"Unknown content source '{source}'.");
|
||||||
|
@ -201,6 +162,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
return this.ContentCore.InvalidateCache(predicate).Any();
|
return this.ContentCore.InvalidateCache(predicate).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Private methods
|
** Private methods
|
||||||
*********/
|
*********/
|
||||||
|
@ -214,170 +176,5 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
if (Path.IsPathRooted(key))
|
if (Path.IsPathRooted(key))
|
||||||
throw new ArgumentException("The asset key must not be an absolute path.");
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue