diff options
author | Ameisen <14104310+ameisen@users.noreply.github.com> | 2022-05-29 18:11:23 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2022-06-10 00:04:25 -0400 |
commit | 5585f5e876459e1764e44754b5b7d933fa1456de (patch) | |
tree | a57797fec7531b73f712ec8a1dbe40c9314d2ee3 /src/SMAPI/Framework/ContentManagers | |
parent | 9d21e0bbecb64243cf98e6c9a10ed5aa4ceed2bf (diff) | |
download | SMAPI-5585f5e876459e1764e44754b5b7d933fa1456de.tar.gz SMAPI-5585f5e876459e1764e44754b5b7d933fa1456de.tar.bz2 SMAPI-5585f5e876459e1764e44754b5b7d933fa1456de.zip |
Refactored ModContentManager.cs so it actually fit on my 1440p screens.
Changed LocalTilesheetExtensions into an array.
Marked 'CreateTemporary' as 'Obsolete' which is conventional for methods that only throw.
Moved the type validation logic into its own method as it's largely shared for each loader.
Changed allocators to use `GC.AllocateUninitializedArray`, as the data does not need to be initialized.
Changed `LoadRawImageData` to use a `ValueTuple` return instead of returning with multiple `out`s, which is bad practice.
Preferred rethrowing handlers rather than exception filters (which generate bizarre and _very difficult to patch_ code).
Marked GetLoadError as debugger step through and hidden, as it's just an exception generator.
Marked PremultiplyTransparency, GetContentKeyForTilesheetImageSource, and LoadRawImageData as static as they have no dependency on instance data (nor should they).
Fixed `.xnb` extension search to properly use OrdinalIgnoreCase.
Diffstat (limited to 'src/SMAPI/Framework/ContentManagers')
-rw-r--r-- | src/SMAPI/Framework/ContentManagers/ModContentManager.cs | 239 |
1 files changed, 183 insertions, 56 deletions
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index e1d9ce78..fe5aaf5d 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,11 +1,11 @@ using System; -using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Drawing; using System.Globalization; using System.IO; using System.Linq; using BmFont; -using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using SkiaSharp; @@ -19,11 +19,12 @@ using StardewValley; using xTile; using xTile.Format; using xTile.Tiles; +using Color = Microsoft.Xna.Framework.Color; namespace StardewModdingAPI.Framework.ContentManagers { /// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> - internal class ModContentManager : BaseContentManager + internal sealed class ModContentManager : BaseContentManager { /********* ** Fields @@ -44,7 +45,7 @@ namespace StardewModdingAPI.Framework.ContentManagers private readonly IFileLookup FileLookup; /// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary> - private static readonly HashSet<string> LocalTilesheetExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".xnb" }; + private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; /********* @@ -64,8 +65,21 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> /// <param name="fileLookup">A lookup for files within the <paramref name="rootDirectory"/>.</param> /// <param name="useRawImageLoading">Whether to use raw image data when possible, instead of initializing an XNA Texture2D instance through the GPU.</param> - public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, IFileLookup fileLookup, bool useRawImageLoading) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) + public ModContentManager( + string name, + IContentManager gameContentManager, + IServiceProvider serviceProvider, + string modName, + string rootDirectory, + CultureInfo currentCulture, + ContentCoordinator coordinator, + IMonitor monitor, + Reflector reflection, + JsonHelper jsonHelper, + Action<BaseContentManager> onDisposing, + IFileLookup fileLookup, + bool useRawImageLoading + ) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) { this.GameContentManager = gameContentManager; this.FileLookup = fileLookup; @@ -102,7 +116,14 @@ namespace StardewModdingAPI.Framework.ContentManagers if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) { if (contentManagerID != this.Name) - throw this.GetLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager."); + { + throw this.GetLoadError( + assetName, + ContentLoadErrorType.AccessDenied, + "can't load a different mod's managed asset key through this mod content manager." + ); + } + assetName = relativePath; } } @@ -127,7 +148,11 @@ namespace StardewModdingAPI.Framework.ContentManagers _ => this.HandleUnknownFileType<T>(assetName, file) }; } - catch (Exception ex) when (ex is not SContentLoadException) + catch (SContentLoadException) + { + throw; + } + catch (Exception ex) { throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected error occurred.", ex); } @@ -138,6 +163,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <inheritdoc /> + [Obsolete($"Temporary {nameof(ModContentManager)}s are unsupported")] public override LocalizedContentManager CreateTemporary() { throw new NotSupportedException("Can't create a temporary mod content manager."); @@ -157,6 +183,67 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ + /// <summary> + /// Validates that the provided <typeparamref name="TInput">type</typeparamref> is compatible with <typeparamref name="TExpected"/>. + /// </summary> + /// <typeparam name="TInput">Type to validate compatibility of.</typeparam> + /// <typeparam name="TExpected">Type to validate compatibility against.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file being loaded.</param> + /// <param name="exception">The exception to throw if the type validation fails, otherwise <see langword="null"/>.</param> + /// <returns><see langword="true"/> if the type validation succeeds, otherwise <see langword="false"/></returns> + private bool ValidateType<TInput, TExpected>( + IAssetName assetName, + FileInfo file, + [NotNullWhen(false)] out SContentLoadException? exception + ) + { + if (typeof(TInput).IsAssignableFrom(typeof(TExpected))) + { + exception = null; + return true; + } + + exception = this.GetLoadError( + assetName, + ContentLoadErrorType.InvalidData, + $"can't read file with extension '{file.Extension}' as type '{typeof(TInput)}'; must be type '{typeof(TExpected)}'." + ); + return false; + } + + /// <summary> + /// Validates that the provided <typeparamref name="TInput">type</typeparamref> + /// is compatible with <typeparamref name="TExpected0"/> or <typeparamref name="TExpected1"/> + /// </summary> + /// <typeparam name="TInput">Type to validate compatibility of.</typeparam> + /// <typeparam name="TExpected0">First type to validate compatibility against.</typeparam> + /// /// <typeparam name="TExpected1">Second type to validate compatibility against.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file being loaded.</param> + /// <param name="exception">The exception to throw if the type validation fails, otherwise <see langword="null"/>.</param> + /// <returns><see langword="true"/> if the type validation succeeds, otherwise <see langword="false"/></returns> + private bool ValidateType<TInput, TExpected0, TExpected1>( + IAssetName assetName, + FileInfo file, + [NotNullWhen(false)] out SContentLoadException? exception + ) + { + if (typeof(TInput).IsAssignableFrom(typeof(TExpected0)) || typeof(TInput).IsAssignableFrom(typeof(TExpected1))) + { + exception = null; + return true; + } + + exception = this.GetLoadError( + assetName, + ContentLoadErrorType.InvalidData, + $"can't read file with extension '{file.Extension}' as type '{typeof(TInput)}'; must be type '{typeof(TExpected0)}' or '{typeof(TExpected1)}'." + ); + return false; + } + + /// <summary>Load an unpacked font file (<c>.fnt</c>).</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset name relative to the loader root directory.</param> @@ -164,8 +251,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadFont<T>(IAssetName assetName, FileInfo file) { // validate - if (!typeof(T).IsAssignableFrom(typeof(XmlSource))) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'."); + if (!this.ValidateType<T, XmlSource>(assetName, file, out var exception)) + { + throw exception; + } // load string source = File.ReadAllText(file.FullName); @@ -179,7 +268,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadDataFile<T>(IAssetName assetName, FileInfo file) { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method + { + // should never happen as we check for file existence before calling this method + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); + } return asset; } @@ -191,24 +283,23 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadImageFile<T>(IAssetName assetName, FileInfo file) { // validate type - bool asRawData = false; - if (typeof(T) != typeof(Texture2D)) + if (!this.ValidateType<T, Texture2D, IRawTextureData>(assetName, file, out var exception)) { - asRawData = typeof(T) == typeof(IRawTextureData); - if (!asRawData) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}' or '{typeof(IRawTextureData)}'."); + throw exception; } + bool asRawData = typeof(T).IsAssignableTo(typeof(IRawTextureData)); + // load if (asRawData || this.UseRawImageLoading) { - this.LoadRawImageData(file, out int width, out int height, out Color[] pixels, asRawData); + (Size size, Color[] pixels) = ModContentManager.LoadRawImageData(file, asRawData); if (asRawData) - return (T)(object)new RawTextureData(width, height, pixels); + return (T)(object)new RawTextureData(size.Width, size.Height, pixels); else { - Texture2D texture = new(Game1.graphics.GraphicsDevice, width, height); + Texture2D texture = new(Game1.graphics.GraphicsDevice, size.Width, size.Height); texture.SetData(pixels); return (T)(object)texture; } @@ -217,34 +308,32 @@ namespace StardewModdingAPI.Framework.ContentManagers { using FileStream stream = File.OpenRead(file.FullName); Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); + texture = ModContentManager.PremultiplyTransparency(texture); return (T)(object)texture; } } /// <summary>Load the raw image data from a file on disk.</summary> /// <param name="file">The file whose data to load.</param> - /// <param name="width">The pixel width for the loaded image data.</param> - /// <param name="height">The pixel height for the loaded image data.</param> - /// <param name="pixels">The premultiplied pixel data.</param> /// <param name="forRawData">Whether the data is being loaded for an <see cref="IRawTextureData"/> (true) or <see cref="Texture2D"/> (false) instance.</param> /// <remarks>This is separate to let framework mods intercept the data before it's loaded, if needed.</remarks> [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")] [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "The 'forRawData' parameter is only added for mods which may intercept this method.")] - private void LoadRawImageData(FileInfo file, out int width, out int height, out Color[] pixels, bool forRawData) + private static (Size Size, Color[] Data) LoadRawImageData(FileInfo file, bool forRawData) { + Size size; + // load raw data SKPMColor[] rawPixels; { using FileStream stream = File.OpenRead(file.FullName); using SKBitmap bitmap = SKBitmap.Decode(stream); rawPixels = SKPMColor.PreMultiply(bitmap.Pixels); - width = bitmap.Width; - height = bitmap.Height; + size = new(bitmap.Width, bitmap.Height); } // convert to XNA pixel format - pixels = new Color[rawPixels.Length]; + var pixels = GC.AllocateUninitializedArray<Color>(rawPixels.Length); for (int i = 0; i < pixels.Length; i++) { SKPMColor pixel = rawPixels[i]; @@ -252,6 +341,8 @@ namespace StardewModdingAPI.Framework.ContentManagers ? Color.Transparent : new Color(r: pixel.Red, g: pixel.Green, b: pixel.Blue, alpha: pixel.Alpha); } + + return (size, pixels); } /// <summary>Load an unpacked image file (<c>.tbin</c> or <c>.tmx</c>).</summary> @@ -261,8 +352,10 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadMapFile<T>(IAssetName assetName, FileInfo file) { // validate - if (typeof(T) != typeof(Map)) - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + if (!this.ValidateType<T, Map>(assetName, file, out var exception)) + { + throw exception; + } // load FormatManager formatManager = FormatManager.Instance; @@ -277,8 +370,14 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset name relative to the loader root directory.</param> private T LoadXnbFile<T>(IAssetName assetName) { - if (typeof(T) == typeof(IRawTextureData)) - throw this.GetLoadError(assetName, ContentLoadErrorType.Other, $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file."); + if (typeof(IRawTextureData).IsAssignableFrom(typeof(T))) + { + throw this.GetLoadError( + assetName, + ContentLoadErrorType.Other, + $"can't read XNB file as type {typeof(IRawTextureData)}; that type can only be read from a PNG file." + ); + } // the underlying content manager adds a .xnb extension implicitly, so // we need to strip it here to avoid trying to load a '.xnb.xnb' file. @@ -303,7 +402,11 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="file">The file to load.</param> private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file) { - throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); + throw this.GetLoadError( + assetName, + ContentLoadErrorType.InvalidName, + $"unknown file extension '{file.Extension}'; must be one of: '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'." + ); } /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary> @@ -311,6 +414,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset name that failed to load.</param> /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param> /// <param name="exception">The underlying exception, if applicable.</param> + [DebuggerStepThrough, DebuggerHidden] private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null) { return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); @@ -325,16 +429,16 @@ namespace StardewModdingAPI.Framework.ContentManagers FileInfo file = this.FileLookup.GetFile(path); // try with default image extensions - if (!file.Exists && typeof(Texture2D).IsAssignableFrom(typeof(T)) && !ModContentManager.LocalTilesheetExtensions.Contains(file.Extension)) + if (file.Exists || !typeof(Texture2D).IsAssignableFrom(typeof(T)) || ModContentManager.LocalTilesheetExtensions.Contains(file.Extension)) + return file; + + foreach (string extension in ModContentManager.LocalTilesheetExtensions) { - foreach (string extension in ModContentManager.LocalTilesheetExtensions) + FileInfo result = new(file.FullName + extension); + if (result.Exists) { - FileInfo result = new(file.FullName + extension); - if (result.Exists) - { - file = result; - break; - } + file = result; + break; } } @@ -345,10 +449,10 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="texture">The texture to premultiply.</param> /// <returns>Returns a premultiplied texture.</returns> /// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks> - private Texture2D PremultiplyTransparency(Texture2D texture) + private static Texture2D PremultiplyTransparency(Texture2D texture) { // premultiply pixels - Color[] data = new Color[texture.Width * texture.Height]; + Color[] data = GC.AllocateUninitializedArray<Color>(texture.Width * texture.Height); texture.GetData(data); bool changed = false; for (int i = 0; i < data.Length; i++) @@ -357,7 +461,12 @@ namespace StardewModdingAPI.Framework.ContentManagers if (pixel.A is (byte.MinValue or byte.MaxValue)) continue; // no need to change fully transparent/opaque pixels - data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) + data[i] = new Color( + pixel.R * pixel.A / byte.MaxValue, + pixel.G * pixel.A / byte.MaxValue, + pixel.B * pixel.A / byte.MaxValue, + pixel.A + ); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) changed = true; } @@ -370,7 +479,10 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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> - /// <param name="fixEagerPathPrefixes">Whether to undo the game's eager tilesheet path prefixing for maps loaded from an <c>.xnb</c> file, which incorrectly prefixes tilesheet paths with the map's local asset key folder.</param> + /// <param name="fixEagerPathPrefixes"> + /// Whether to undo the game's eager tilesheet path prefixing for maps loaded from an <c>.xnb</c> file, + /// which incorrectly prefixes tilesheet paths with the map's local asset key folder. + /// </param> /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes) { @@ -388,18 +500,28 @@ namespace StardewModdingAPI.Framework.ContentManagers // reverse incorrect eager tilesheet path prefixing if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder)) - imageSource = imageSource.Substring(relativeMapFolder.Length + 1); + imageSource = imageSource[(relativeMapFolder.Length + 1)..]; // validate tilesheet path string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); + { + throw new SContentLoadException( + ContentLoadErrorType.InvalidData, + $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)." + ); + } // load best match try { if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error)) - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} {error}"); + { + throw new SContentLoadException( + ContentLoadErrorType.InvalidData, + $"{errorPrefix} {error}" + ); + } if (assetName is not null) { @@ -409,7 +531,11 @@ namespace StardewModdingAPI.Framework.ContentManagers tilesheet.ImageSource = assetName.Name; } } - catch (Exception ex) when (ex is not SContentLoadException) + catch (SContentLoadException) + { + throw; + } + catch (Exception ex) { throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} The tilesheet couldn't be loaded.", ex); } @@ -425,7 +551,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks> private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error) { - assetName = null; error = null; // nothing to do @@ -440,7 +565,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // opened in Tiled, while still mapping it to the vanilla 'Maps/spring_town' asset at runtime. { string filename = Path.GetFileName(relativePath); - if (filename.StartsWith(".")) + if (filename.StartsWith('.')) relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.')); } @@ -455,10 +580,11 @@ namespace StardewModdingAPI.Framework.ContentManagers } // get from game assets - IAssetName contentKey = this.Coordinator.ParseAssetName(this.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false); + AssetName contentKey = this.Coordinator.ParseAssetName(ModContentManager.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false); try { - this.GameContentManager.LoadLocalized<Texture2D>(contentKey, this.GameContentManager.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + // no need to bypass cache here, since we're not storing the asset + this.GameContentManager.LoadLocalized<Texture2D>(contentKey, this.GameContentManager.Language, useCache: true); assetName = contentKey; return true; } @@ -476,6 +602,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } // not found + assetName = null; error = "The tilesheet couldn't be found relative to either map file or the game's content folder."; return false; } @@ -486,16 +613,16 @@ namespace StardewModdingAPI.Framework.ContentManagers { // get file path string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); - if (!path.EndsWith(".xnb")) + if (!path.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)) path += ".xnb"; // get file - return new FileInfo(path).Exists; + return File.Exists(path); } /// <summary>Get the asset key for a tilesheet in the game's <c>Maps</c> content folder.</summary> /// <param name="relativePath">The tilesheet image source.</param> - private string GetContentKeyForTilesheetImageSource(string relativePath) + private static string GetContentKeyForTilesheetImageSource(string relativePath) { string key = relativePath; string topFolder = PathUtilities.GetSegments(key, limit: 2)[0]; @@ -506,7 +633,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // remove file extension from unpacked file if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) - key = key.Substring(0, key.Length - 4); + key = key[..^4]; return key; } |