using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Threading; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities.PathLookups; using StardewValley; using StardewValley.GameData; using xTile; namespace StardewModdingAPI.Framework { /// The central logic for creating content managers, invalidating caches, and propagating asset changes. internal class ContentCoordinator : IDisposable { /********* ** Fields *********/ /// An asset key prefix for assets from SMAPI mod folders. private readonly string ManagedPrefix = "SMAPI"; /// Get a file lookup for the given directory. private readonly Func GetFileLookup; /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; /// Provides metadata for core game assets. private readonly CoreAssetPropagator CoreAssets; /// Simplifies access to private code. private readonly Reflector Reflection; /// Encapsulates SMAPI's JSON file parsing. private readonly JsonHelper JsonHelper; /// A callback to invoke the first time *any* game content manager loads an asset. private readonly Action OnLoadingFirstAsset; /// A callback to invoke when an asset is fully loaded. private readonly Action OnAssetLoaded; /// A callback to invoke when any asset names have been invalidated from the cache. private readonly Action> OnAssetsInvalidated; /// Get the load/edit operations to apply to an asset by querying registered event handlers. private readonly Func> RequestAssetOperations; /// The loaded content managers (including the ). private readonly List ContentManagers = new(); /// Whether the content coordinator has been disposed. private bool IsDisposed; /// A lock used to prevent asynchronous changes to the content manager list. /// The game may add content managers in asynchronous threads (e.g. when populating the load screen). private readonly ReaderWriterLockSlim ContentManagerLock = new(); /// A cache of ordered tilesheet IDs used by vanilla maps. private readonly Dictionary VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase); /// An unmodified content manager which doesn't intercept assets, used to compare asset data. private readonly LocalizedContentManager VanillaContentManager; /// The language enum values indexed by locale code. private Lazy> LocaleCodes; /// The cached asset load/edit operations to apply, indexed by asset name. private readonly TickCacheDictionary AssetOperationsByKey = new(); /// A cache of asset operation groups created for legacy implementations. [Obsolete] private readonly Dictionary> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); /// A cache of asset operation groups created for legacy implementations. [Obsolete] private readonly Dictionary> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); /********* ** Accessors *********/ /// The primary content manager used for most assets. public GameContentManager MainContentManager { get; private set; } /// The current language as a constant. public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; /// Interceptors which provide the initial versions of matching assets. [Obsolete] public IList> Loaders { get; } = new List>(); /// Interceptors which edit matching assets after they're loaded. [Obsolete] public IList> Editors { get; } = new List>(); /// The absolute path to the . public string FullRootDirectory { get; } /********* ** Public methods *********/ /// Construct an instance. /// The service provider to use to locate services. /// The root directory to search for content. /// The current culture for which to localize content. /// Encapsulates monitoring and logging. /// Simplifies access to private code. /// Encapsulates SMAPI's JSON file parsing. /// A callback to invoke the first time *any* game content manager loads an asset. /// A callback to invoke when an asset is fully loaded. /// Get a file lookup for the given directory. /// A callback to invoke when any asset names have been invalidated from the cache. /// Get the load/edit operations to apply to an asset by querying registered event handlers. public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action onAssetLoaded, Func getFileLookup, Action> onAssetsInvalidated, Func> requestAssetOperations) { this.GetFileLookup = getFileLookup; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.JsonHelper = jsonHelper; this.OnLoadingFirstAsset = onLoadingFirstAsset; this.OnAssetLoaded = onAssetLoaded; this.OnAssetsInvalidated = onAssetsInvalidated; this.RequestAssetOperations = requestAssetOperations; this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); this.ContentManagers.Add( this.MainContentManager = new GameContentManager( name: "Game1.content", serviceProvider: serviceProvider, rootDirectory: rootDirectory, currentCulture: currentCulture, coordinator: this, monitor: monitor, reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, onAssetLoaded: onAssetLoaded ) ); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, this.Monitor, reflection, name => this.ParseAssetName(name, allowLocales: true)); this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty())); } /// Get a new content manager which handles reading files from the game content folder with support for interception. /// A name for the mod manager. Not guaranteed to be unique. public GameContentManager CreateGameContentManager(string name) { return this.ContentManagerLock.InWriteLock(() => { GameContentManager manager = new( name: name, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: this.MainContentManager.RootDirectory, currentCulture: this.MainContentManager.CurrentCulture, coordinator: this, monitor: this.Monitor, reflection: this.Reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: this.OnLoadingFirstAsset, onAssetLoaded: this.OnAssetLoaded ); this.ContentManagers.Add(manager); return manager; }); } /// Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files. /// A name for the mod manager. Not guaranteed to be unique. /// The mod display name to show in errors. /// The root directory to search for content (or null for the default). /// The game content manager used for map tilesheets not provided by the mod. public ModContentManager CreateModContentManager(string name, string modName, string rootDirectory, IContentManager gameContentManager) { return this.ContentManagerLock.InWriteLock(() => { ModContentManager manager = new( name: name, gameContentManager: gameContentManager, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: rootDirectory, modName: modName, currentCulture: this.MainContentManager.CurrentCulture, coordinator: this, monitor: this.Monitor, reflection: this.Reflection, jsonHelper: this.JsonHelper, onDisposing: this.OnDisposing, fileLookup: this.GetFileLookup(rootDirectory) ); this.ContentManagers.Add(manager); return manager; }); } /// Get the current content locale. public string GetLocale() { return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } /// Perform any updates needed when the game loads custom languages from Data/AdditionalLanguages. public void OnAdditionalLanguagesInitialized() { // update locale cache for custom languages, and load it now (since languages added later won't work) var customLanguages = this.MainContentManager.Load>("Data/AdditionalLanguages"); this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages)); _ = this.LocaleCodes.Value; } /// Perform any updates needed when the locale changes. public void OnLocaleChanged() { // reset baseline cache this.ContentManagerLock.InReadLock(() => { this.VanillaContentManager.Unload(); }); } /// Clean up when the player is returning to the title screen. /// This is called after the player returns to the title screen, but before runs. public void OnReturningToTitleScreen() { // The game clears LocalizedContentManager.localizedAssetNames after returning to the title screen. That // causes an inconsistency in the SMAPI asset cache, which leads to an edge case where assets already // provided by mods via IAssetLoader when playing in non-English are ignored. // // For example, let's say a mod provides the 'Data\mail' asset through IAssetLoader when playing in // Portuguese. Here's the normal load process after it's loaded: // 1. The game requests Data\mail. // 2. SMAPI sees that it's already cached, and calls LoadRaw to bypass asset interception. // 3. LoadRaw sees that there's a localized key mapping, and gets the mapped key. // 4. In this case "Data\mail" is mapped to "Data\mail" since it was loaded by a mod, so it loads that // asset. // // When the game clears localizedAssetNames, that process goes wrong in step 4: // 3. LoadRaw sees that there's no localized key mapping *and* the locale is non-English, so it attempts // to load from the localized key format. // 4. In this case that's 'Data\mail.pt-BR', so it successfully loads that asset. // 5. Since we've bypassed asset interception at this point, it's loaded directly from the base content // manager without mod changes. // // To avoid issues, we just remove affected assets from the cache here so they'll be reloaded normally. // Note that we *must* propagate changes here, otherwise when mods invalidate the cache later to reapply // their changes, the assets won't be found in the cache so no changes will be propagated. if (LocalizedContentManager.CurrentLanguageCode != LocalizedContentManager.LanguageCode.en) this.InvalidateCache((contentManager, _, _) => contentManager is GameContentManager); } /// Parse a raw asset name. /// The raw asset name to parse. /// Whether to parse locales in the . If this is false, any locale codes in the name are treated as if they were part of the base name (e.g. for mod files). /// The is null or empty. public AssetName ParseAssetName(string rawName, bool allowLocales) { return !string.IsNullOrWhiteSpace(rawName) ? AssetName.Parse( rawName: rawName, parseLocale: allowLocales ? locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null : _ => null ) : throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); } /// Get whether this asset is mapped to a mod folder. /// The asset name. public bool IsManagedAssetKey(IAssetName key) { return key.StartsWith(this.ManagedPrefix); } /// Parse a managed SMAPI asset key which maps to a mod folder. /// The asset key. /// The unique name for the content manager which should load this asset. /// The asset name within the mod folder. /// Returns whether the asset was parsed successfully. public bool TryParseManagedAssetKey(string key, [NotNullWhen(true)] out string? contentManagerID, [NotNullWhen(true)] out IAssetName? relativePath) { contentManagerID = null; relativePath = null; // not a managed asset if (!key.StartsWith(this.ManagedPrefix)) return false; // parse string[] parts = PathUtilities.GetSegments(key, 3); if (parts.Length != 3) // managed key prefix, mod id, relative path return false; contentManagerID = Path.Combine(parts[0], parts[1]); relativePath = this.ParseAssetName(parts[2], allowLocales: false); return true; } /// Get the managed asset key prefix for a mod. /// The mod's unique ID. public string GetManagedAssetPrefix(string modID) { return Path.Combine(this.ManagedPrefix, modID.ToLower()); } /// Get whether an asset from a mod folder exists. /// The expected asset type. /// The unique name for the content manager which should load this asset. /// The asset name within the mod folder. public bool DoesManagedAssetExist(string contentManagerID, IAssetName assetName) where T : notnull { // get content manager IContentManager? contentManager = this.ContentManagerLock.InReadLock(() => this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) ); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); // get whether the asset exists return contentManager.DoesAssetExist(assetName); } /// Get a copy of an asset from a mod folder. /// The asset type. /// The unique name for the content manager which should load this asset. /// The asset name within the mod folder. public T LoadManagedAsset(string contentManagerID, IAssetName relativePath) where T : notnull { // get content manager IContentManager? contentManager = this.ContentManagerLock.InReadLock(() => this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) ); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); // get fresh asset return contentManager.LoadExact(relativePath, useCache: false); } /// Purge matched assets from the cache. /// Matches the asset keys to invalidate. /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. /// Returns the invalidated asset keys. public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { string locale = this.GetLocale(); return this.InvalidateCache((_, rawName, type) => { IAssetName assetName = this.ParseAssetName(rawName, allowLocales: true); IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); } /// Purge matched assets from the cache. /// Matches the asset keys to invalidate. /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. /// Returns the invalidated asset names. public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { // invalidate cache & track removed assets IDictionary invalidatedAssets = new Dictionary(); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach ((string key, object asset) in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { AssetName assetName = this.ParseAssetName(key, allowLocales: true); if (!invalidatedAssets.ContainsKey(assetName)) invalidatedAssets[assetName] = asset.GetType(); } } // special case: maps may be loaded through a temporary content manager that's removed while the map is still in use. // This notably affects the town and farmhouse maps. if (Game1.locations != null) { foreach (GameLocation location in Game1.locations) { if (location.map == null || string.IsNullOrWhiteSpace(location.mapPath.Value)) continue; // get map path AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value), allowLocales: true); if (!invalidatedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map))) invalidatedAssets[mapPath] = typeof(Map); } } }); // handle invalidation if (invalidatedAssets.Any()) { // clear cached editor checks foreach (IAssetName name in invalidatedAssets.Keys) this.AssetOperationsByKey.Remove(name); // raise event this.OnAssetsInvalidated(invalidatedAssets.Keys.ToArray()); // propagate changes to the game this.CoreAssets.Propagate( assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, out IDictionary propagated, out bool updatedNpcWarps ); // log summary StringBuilder report = new(); { IAssetName[] invalidatedKeys = invalidatedAssets.Keys.ToArray(); IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); string FormatKeyList(IEnumerable keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)})."); report.AppendLine(propagated.Count > 0 ? $"Propagated {propagatedKeys.Length} core assets ({FormatKeyList(propagatedKeys)})." : "Propagated 0 core assets." ); if (updatedNpcWarps) report.AppendLine("Updated NPC pathfinding cache."); } this.Monitor.Log(report.ToString().TrimEnd()); } else this.Monitor.Log("Invalidated 0 cache entries."); return invalidatedAssets.Keys; } /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now. /// The asset type. /// The asset info to load or edit. public IEnumerable GetAssetOperations(IAssetInfo info) where T : notnull { return this.AssetOperationsByKey.GetOrSet( info.Name, () => this.GetAssetOperationsWithoutCache(info).ToArray() ); } /// Get all loaded instances of an asset name. /// The asset name. [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")] public IEnumerable GetLoadedValues(IAssetName assetName) { return this.ContentManagerLock.InReadLock(() => { List values = new List(); foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName))) { object value = content.LoadExact(assetName, useCache: true); values.Add(value); } return values; }); } /// Get the tilesheet ID order used by the unmodified version of a map asset. /// The asset path relative to the loader root directory, not including the .xnb extension. public TilesheetReference[] GetVanillaTilesheetIds(string assetName) { if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[]? tilesheets)) { tilesheets = this.TryLoadVanillaAsset(assetName, out Map? map) ? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource, sheet.SheetSize, sheet.TileSize)).ToArray() : null; this.VanillaTilesheets[assetName] = tilesheets; this.VanillaContentManager.Unload(); } return tilesheets ?? Array.Empty(); } /// Get the locale code which corresponds to a language enum (e.g. fr-FR given ). /// The language enum to search. public string? GetLocaleCode(LocalizedContentManager.LanguageCode language) { if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null) return null; return this.MainContentManager.LanguageCodeString(language); } /// Dispose held resources. public void Dispose() { if (this.IsDisposed) return; this.IsDisposed = true; this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point."); foreach (IContentManager contentManager in this.ContentManagers) contentManager.Dispose(); this.ContentManagers.Clear(); this.MainContentManager = null!; // instance no longer usable this.ContentManagerLock.Dispose(); } /********* ** Private methods *********/ /// A callback invoked when a content manager is disposed. /// The content manager being disposed. private void OnDisposing(IContentManager contentManager) { if (this.IsDisposed) return; this.ContentManagerLock.InWriteLock(() => this.ContentManagers.Remove(contentManager) ); } /// Get a vanilla asset without interception. /// The type of asset to load. /// The asset path relative to the loader root directory, not including the .xnb extension. /// The loaded asset data. private bool TryLoadVanillaAsset(string assetName, [NotNullWhen(true)] out T? asset) where T : notnull { try { asset = this.VanillaContentManager.Load(assetName); return true; } catch { asset = default; return false; } } /// Get the language enums (like ) indexed by locale code (like ja-JP). /// The custom languages to add to the lookup. private Dictionary GetLocaleCodes(IEnumerable customLanguages) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); // custom languages foreach (ModLanguage? language in customLanguages) { if (!string.IsNullOrWhiteSpace(language?.LanguageCode)) map[language.LanguageCode] = LocalizedContentManager.LanguageCode.mod; } // vanilla languages (override custom language if they conflict) foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode))) { string? locale = this.GetLocaleCode(code); if (locale != null) map[locale] = code; } return map; } /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the cache. /// The asset type. /// The asset info to load or edit. private IEnumerable GetAssetOperationsWithoutCache(IAssetInfo info) where T : notnull { IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); // new content API foreach (AssetOperationGroup group in this.RequestAssetOperations(info)) yield return group; // legacy load operations #pragma warning disable CS0612, CS0618 // deprecated code foreach (ModLinked loader in this.Loaders) { // check if loader applies try { if (!loader.Data.CanLoad(legacyInfo)) continue; } catch (Exception ex) { loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } // add operation yield return this.GetOrCreateLegacyOperationGroup( cache: this.LegacyLoaderCache, editor: loader.Data, dataType: info.DataType, createGroup: () => new AssetOperationGroup( mod: loader.Mod, loadOperations: new[] { new AssetLoadOperation( mod: loader.Mod, priority: AssetLoadPriority.Exclusive, onBehalfOf: null, getData: assetInfo => loader.Data.Load( this.GetLegacyAssetInfo(assetInfo) ) ) }, editOperations: Array.Empty() ) ); } // legacy edit operations foreach (var editor in this.Editors) { // check if editor applies try { if (!editor.Data.CanEdit(legacyInfo)) continue; } catch (Exception ex) { editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } // HACK // // If two editors have the same priority, they're applied in registration order (so // whichever was registered first is applied first). Mods often depend on this // behavior, like Json Assets registering its interceptors before Content Patcher. // // Unfortunately the old & new content APIs have separate lists, so new-API // interceptors always ran before old-API interceptors with the same priority, // regardless of the registration order *between* APIs. Since the new API works in // a fundamentally different way (i.e. loads/edits are defined on asset request // instead of by registering a global 'editor' or 'loader' class), there's no way // to track registration order between them. // // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for // specific legacy editors to maintain compatibility. AssetEditPriority priority = editor.Data.GetType().FullName switch { "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher _ => AssetEditPriority.Default }; // add operation yield return this.GetOrCreateLegacyOperationGroup( cache: this.LegacyEditorCache, editor: editor.Data, dataType: info.DataType, createGroup: () => new AssetOperationGroup( mod: editor.Mod, loadOperations: Array.Empty(), editOperations: new[] { new AssetEditOperation( mod: editor.Mod, priority: priority, onBehalfOf: null, applyEdit: assetData => editor.Data.Edit( this.GetLegacyAssetData(assetData) ) ) } ) ); } #pragma warning restore CS0612, CS0618 } /// Get a cached asset operation group for a legacy or instance, creating it if needed. /// The editor type (one of or ). /// The cached operation groups for the interceptor type. /// The legacy asset interceptor. /// The asset data type. /// Create the asset operation group if it's not cached yet. private AssetOperationGroup GetOrCreateLegacyOperationGroup(Dictionary> cache, TInterceptor editor, Type dataType, Func createGroup) where TInterceptor : class { if (!cache.TryGetValue(editor, out Dictionary? cacheByType)) cache[editor] = cacheByType = new Dictionary(); if (!cacheByType.TryGetValue(dataType, out AssetOperationGroup? group)) cacheByType[dataType] = group = createGroup(); return group; } /// Get an asset info compatible with legacy and instances, which always expect the base name. /// The asset info. private IAssetInfo GetLegacyAssetInfo(IAssetInfo asset) { return new AssetInfo( locale: this.GetLegacyLocale(asset), assetName: this.GetLegacyAssetName(asset.Name), type: asset.DataType, getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName ); } /// Get an asset data compatible with legacy and instances, which always expect the base name. /// The asset data. private IAssetData GetLegacyAssetData(IAssetData asset) { return new AssetDataForObject( locale: this.GetLegacyLocale(asset), assetName: this.GetLegacyAssetName(asset.Name), data: asset.Data, getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName, reflection: this.Reflection, onDataReplaced: asset.ReplaceWith ); } /// Get the value compatible with legacy and instances, which expect the locale to default to the current game locale or an empty string. /// The non-legacy asset info to map. private string GetLegacyLocale(IAssetInfo asset) { return asset.Locale ?? this.GetLocale(); } /// Get an asset name compatible with legacy and instances, which always expect the base name. /// The asset name to map. /// Returns the legacy asset name if needed, or the if no change is needed. private IAssetName GetLegacyAssetName(IAssetName asset) { // strip _international suffix const string internationalSuffix = "_international"; if (asset.Name.EndsWith(internationalSuffix)) { return new AssetName( baseName: asset.Name[..^internationalSuffix.Length], localeCode: null, languageCode: null ); } // else strip locale if (asset.LocaleCode != null) return new AssetName(asset.BaseName, null, null); // else no change needed return asset; } } }