using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using ContentPatcher.Framework; using ContentPatcher.Framework.Commands; using ContentPatcher.Framework.Conditions; using ContentPatcher.Framework.ConfigModels; using ContentPatcher.Framework.Lexing; using ContentPatcher.Framework.Lexing.LexTokens; using ContentPatcher.Framework.Migrations; using ContentPatcher.Framework.Patches; using ContentPatcher.Framework.Tokens; using ContentPatcher.Framework.Validators; using Pathoschild.Stardew.Common.Utilities; using StardewModdingAPI; using StardewModdingAPI.Enums; using StardewModdingAPI.Events; namespace ContentPatcher { /// The mod entry point. internal class ModEntry : Mod { /********* ** Fields *********/ /// The name of the file which contains patch metadata. private readonly string PatchFileName = "content.json"; /// The name of the file which contains player settings. private readonly string ConfigFileName = "config.json"; /// The supported format versions. private readonly string[] SupportedFormatVersions = { "1.0", "1.3", "1.4", "1.5", "1.6" }; /// The format version migrations to apply. private readonly Func Migrations = () => new IMigration[] { new Migration_1_3(), new Migration_1_4(), new Migration_1_5(), new Migration_1_6() }; /// The special validation logic to apply to assets affected by patches. private readonly Func AssetValidators = () => new IAssetValidator[] { new StardewValley_1_3_36_Validator() }; /// Manages the available contextual tokens. private TokenManager TokenManager; /// Manages loaded patches. private PatchManager PatchManager; /// Handles the 'patch' console command. private CommandHandler CommandHandler; /// The mod configuration. private ModConfig Config; /// The debug overlay (if enabled). private DebugOverlay DebugOverlay; /********* ** Public methods *********/ /// The mod entry point, called after the mod is first loaded. /// Provides simplified APIs for writing mods. public override void Entry(IModHelper helper) { this.Config = helper.ReadConfig(); // init migrations IMigration[] migrations = this.Migrations(); // fetch content packs RawContentPack[] contentPacks = this.GetContentPacks(migrations).ToArray(); string[] installedMods = (contentPacks.Select(p => p.Manifest.UniqueID)) .Concat(helper.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID)) .OrderByIgnoreCase(p => p) .ToArray(); // load content packs and context this.TokenManager = new TokenManager(helper.Content, installedMods); this.PatchManager = new PatchManager(this.Monitor, this.TokenManager, this.AssetValidators()); this.LoadContentPacks(contentPacks); this.TokenManager.UpdateContext(); // register patcher helper.Content.AssetLoaders.Add(this.PatchManager); helper.Content.AssetEditors.Add(this.PatchManager); // set up events if (this.Config.EnableDebugFeatures) helper.Events.Input.ButtonPressed += this.OnButtonPressed; helper.Events.GameLoop.ReturnedToTitle += this.OnReturnedToTitle; helper.Events.GameLoop.DayStarted += this.OnDayStarted; helper.Events.Specialised.LoadStageChanged += this.OnLoadStageChanged; // set up commands this.CommandHandler = new CommandHandler(this.TokenManager, this.PatchManager, this.Monitor, this.UpdateContext); helper.ConsoleCommands.Add(this.CommandHandler.CommandName, $"Starts a Content Patcher command. Type '{this.CommandHandler.CommandName} help' for details.", (name, args) => this.CommandHandler.Handle(args)); } /********* ** Private methods *********/ /**** ** Event handlers ****/ /// The method invoked when the player presses a button. /// The event sender. /// The event data. private void OnButtonPressed(object sender, ButtonPressedEventArgs e) { if (this.Config.EnableDebugFeatures) { // toggle overlay if (this.Config.Controls.ToggleDebug.Contains(e.Button)) { if (this.DebugOverlay == null) this.DebugOverlay = new DebugOverlay(this.Helper.Events, this.Helper.Input, this.Helper.Content); else { this.DebugOverlay.Dispose(); this.DebugOverlay = null; } return; } // cycle textures if (this.DebugOverlay != null) { if (this.Config.Controls.DebugPrevTexture.Contains(e.Button)) this.DebugOverlay.PrevTexture(); if (this.Config.Controls.DebugNextTexture.Contains(e.Button)) this.DebugOverlay.NextTexture(); } } } /// Raised when the low-level stage in the game's loading process has changed. This is an advanced event for mods which need to run code at specific points in the loading process. The available stages or when they happen might change without warning in future versions (e.g. due to changes in the game's load process), so mods using this event are more likely to break or have bugs. /// The event sender. /// The event data. private void OnLoadStageChanged(object sender, LoadStageChangedEventArgs e) { switch (e.NewStage) { case LoadStage.CreatedBasicInfo: case LoadStage.SaveLoadedBasicInfo: this.Monitor.VerboseLog($"Updating context: load stage changed to {e.NewStage}."); this.TokenManager.IsBasicInfoLoaded = true; this.UpdateContext(); break; } } /// The method invoked when a new day starts. /// The event sender. /// The event data. private void OnDayStarted(object sender, DayStartedEventArgs e) { this.Monitor.VerboseLog("Updating context: new day started."); this.TokenManager.IsBasicInfoLoaded = true; this.UpdateContext(); } /// The method invoked when the player returns to the title screen. /// The event sender. /// The event data. private void OnReturnedToTitle(object sender, ReturnedToTitleEventArgs e) { this.Monitor.VerboseLog("Updating context: returned to title."); this.TokenManager.IsBasicInfoLoaded = false; this.UpdateContext(); } /**** ** Methods ****/ /// Update the current context. private void UpdateContext() { this.TokenManager.UpdateContext(); this.PatchManager.UpdateContext(this.Helper.Content); } /// Load the registered content packs. /// The format version migrations to apply. /// Returns the loaded content pack IDs. [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "The value is used immediately, so this isn't an issue.")] private IEnumerable GetContentPacks(IMigration[] migrations) { this.Monitor.VerboseLog("Preloading content packs..."); foreach (IContentPack contentPack in this.Helper.ContentPacks.GetOwned()) { RawContentPack rawContentPack; try { // validate content.json has required fields ContentConfig content = contentPack.ReadJsonFile(this.PatchFileName); if (content == null) { this.Monitor.Log($"Ignored content pack '{contentPack.Manifest.Name}' because it has no {this.PatchFileName} file.", LogLevel.Error); continue; } if (content.Format == null || content.Changes == null) { this.Monitor.Log($"Ignored content pack '{contentPack.Manifest.Name}' because it doesn't specify the required {nameof(ContentConfig.Format)} or {nameof(ContentConfig.Changes)} fields.", LogLevel.Error); continue; } // apply migrations IMigration migrator = new AggregateMigration(content.Format, this.SupportedFormatVersions, migrations); if (!migrator.TryMigrate(content, out string error)) { this.Monitor.Log($"Loading content pack '{contentPack.Manifest.Name}' failed: {error}.", LogLevel.Error); continue; } // init rawContentPack = new RawContentPack(new ManagedContentPack(contentPack), content, migrator); } catch (Exception ex) { this.Monitor.Log($"Error preloading content pack '{contentPack.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); continue; } yield return rawContentPack; } } /// Load the patches from all registered content packs. /// The content packs to load. /// Returns the loaded content pack IDs. [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "The value is used immediately, so this isn't an issue.")] private void LoadContentPacks(IEnumerable contentPacks) { // load content packs ConfigFileHandler configFileHandler = new ConfigFileHandler(this.ConfigFileName, this.ParseCommaDelimitedField, (pack, label, reason) => this.Monitor.Log($"Ignored {pack.Manifest.Name} > {label}: {reason}")); foreach (RawContentPack current in contentPacks) { this.Monitor.VerboseLog($"Loading content pack '{current.Manifest.Name}'..."); try { ContentConfig content = current.Content; // load tokens ModTokenContext tokenContext = this.TokenManager.TrackLocalTokens(current.ManagedPack.Pack); { // load config.json InvariantDictionary config = configFileHandler.Read(current.ManagedPack, content.ConfigSchema); configFileHandler.Save(current.ManagedPack, config, this.Helper); if (config.Any()) this.Monitor.VerboseLog($" found config.json with {config.Count} fields..."); // load config tokens foreach (KeyValuePair pair in config) { ConfigField field = pair.Value; tokenContext.Add(new ImmutableToken(pair.Key, field.Value, allowedValues: field.AllowValues, canHaveMultipleValues: field.AllowMultiple)); } // load dynamic tokens foreach (DynamicTokenConfig entry in content.DynamicTokens ?? new DynamicTokenConfig[0]) { void LogSkip(string reason) => this.Monitor.Log($"Ignored {current.Manifest.Name} > dynamic token '{entry.Name}': {reason}", LogLevel.Warn); // validate token key if (!TokenName.TryParse(entry.Name, out TokenName name)) { LogSkip("the name could not be parsed as a token key."); continue; } if (name.HasSubkey()) { LogSkip("the token name cannot contain a subkey (:)."); continue; } if (name.TryGetConditionType(out ConditionType conflictingType)) { LogSkip($"conflicts with global token '{conflictingType}'."); continue; } if (config.ContainsKey(name.Key)) { LogSkip($"conflicts with player config token '{conflictingType}'."); continue; } // parse values InvariantHashSet values = entry.Value != null ? this.ParseCommaDelimitedField(entry.Value) : new InvariantHashSet(); // parse conditions ConditionDictionary conditions; { if (!this.TryParseConditions(entry.When, tokenContext, current.Migrator, out conditions, out string error)) { this.Monitor.Log($"Ignored {current.Manifest.Name} > '{entry.Name}' token: its {nameof(DynamicTokenConfig.When)} field is invalid: {error}.", LogLevel.Warn); continue; } } // add token tokenContext.Add(new DynamicTokenValue(name, values, conditions)); } } // load patches content.Changes = this.SplitPatches(content.Changes).ToArray(); this.NamePatches(current.ManagedPack, content.Changes); foreach (PatchConfig patch in content.Changes) { this.Monitor.VerboseLog($" loading {patch.LogName}..."); this.LoadPatch(current.ManagedPack, patch, tokenContext, current.Migrator, logSkip: reasonPhrase => this.Monitor.Log($"Ignored {patch.LogName}: {reasonPhrase}", LogLevel.Warn)); } } catch (Exception ex) { this.Monitor.Log($"Error loading content pack '{current.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); continue; } } } /// Split patches with multiple target values. /// The patches to split. private IEnumerable SplitPatches(IEnumerable patches) { foreach (PatchConfig patch in patches) { if (string.IsNullOrWhiteSpace(patch.Target) || !patch.Target.Contains(",")) { yield return patch; continue; } int i = 0; foreach (string target in patch.Target.Split(',')) { i++; yield return new PatchConfig(patch) { LogName = !string.IsNullOrWhiteSpace(patch.LogName) ? $"{patch.LogName} {"".PadRight(i, 'I')}" : "", Target = target.Trim() }; } } } /// Set a unique name for all patches in a content pack. /// The content pack. /// The patches to name. private void NamePatches(ManagedContentPack contentPack, PatchConfig[] patches) { // add default log names foreach (PatchConfig patch in patches) { if (string.IsNullOrWhiteSpace(patch.LogName)) patch.LogName = $"{patch.Action} {patch.Target}"; } // detect duplicate names InvariantHashSet duplicateNames = new InvariantHashSet( from patch in patches group patch by patch.LogName into nameGroup where nameGroup.Count() > 1 select nameGroup.Key ); // make names unique int i = 0; foreach (PatchConfig patch in patches) { i++; if (duplicateNames.Contains(patch.LogName)) patch.LogName = $"entry #{i} ({patch.LogName})"; patch.LogName = $"{contentPack.Manifest.Name} > {patch.LogName}"; } } /// Load one patch from a content pack's content.json file. /// The content pack being loaded. /// The change to load. /// The tokens available for this content pack. /// The migrator which validates and migrates content pack data. /// The callback to invoke with the error reason if loading it fails. private bool LoadPatch(ManagedContentPack pack, PatchConfig entry, IContext tokenContext, IMigration migrator, Action logSkip) { bool TrackSkip(string reason, bool warn = true) { this.PatchManager.AddPermanentlyDisabled(new DisabledPatch(entry.LogName, entry.Action, entry.Target, pack, reason)); if (warn) logSkip(reason); return false; } try { // normalise patch fields if (entry.When == null) entry.When = new InvariantDictionary(); // parse action if (!Enum.TryParse(entry.Action, true, out PatchType action)) { return TrackSkip(string.IsNullOrWhiteSpace(entry.Action) ? $"must set the {nameof(PatchConfig.Action)} field." : $"invalid {nameof(PatchConfig.Action)} value '{entry.Action}', expected one of: {string.Join(", ", Enum.GetNames(typeof(PatchType)))}." ); } // parse target asset TokenString assetName; { if (string.IsNullOrWhiteSpace(entry.Target)) return TrackSkip($"must set the {nameof(PatchConfig.Target)} field."); if (!this.TryParseTokenString(entry.Target, tokenContext, migrator, out string error, out assetName)) return TrackSkip($"the {nameof(PatchConfig.Target)} is invalid: {error}"); } // parse 'enabled' bool enabled = true; { if (entry.Enabled != null && !this.TryParseEnabled(entry.Enabled, tokenContext, migrator, out string error, out enabled)) return TrackSkip($"invalid {nameof(PatchConfig.Enabled)} value '{entry.Enabled}': {error}"); } // parse conditions ConditionDictionary conditions; { if (!this.TryParseConditions(entry.When, tokenContext, migrator, out conditions, out string error)) return TrackSkip($"the {nameof(PatchConfig.When)} field is invalid: {error}."); } // get patch instance IPatch patch; switch (action) { // load asset case PatchType.Load: { // init patch if (!this.TryPrepareLocalAsset(pack, entry.FromFile, tokenContext, migrator, out string error, out TokenString fromAsset)) return TrackSkip(error); patch = new LoadPatch(entry.LogName, pack, assetName, conditions, fromAsset, this.Helper.Content.NormaliseAssetName); } break; // edit data case PatchType.EditData: { // validate if (entry.Entries == null && entry.Fields == null) return TrackSkip($"either {nameof(PatchConfig.Entries)} or {nameof(PatchConfig.Fields)} must be specified for a '{action}' change."); if (entry.Entries != null && entry.Entries.Any(p => p.Value != null && p.Value.Trim() == "")) return TrackSkip($"the {nameof(PatchConfig.Entries)} can't contain empty values."); if (entry.Fields != null && entry.Fields.Any(p => p.Value == null || p.Value.Any(n => n.Value == null))) return TrackSkip($"the {nameof(PatchConfig.Fields)} can't contain empty values."); // parse entries List entries = new List(); if (entry.Entries != null) { foreach (KeyValuePair pair in entry.Entries) { if (!this.TryParseTokenString(pair.Key, tokenContext, migrator, out string keyError, out TokenString key)) return TrackSkip($"{nameof(PatchConfig.Entries)} > '{key}' key is invalid: {keyError}."); if (!this.TryParseTokenString(pair.Value, tokenContext, migrator, out string error, out TokenString value)) return TrackSkip($"{nameof(PatchConfig.Entries)} > '{key}' value is invalid: {error}."); entries.Add(new EditDataPatchRecord(key, value)); } } // parse fields List fields = new List(); if (entry.Fields != null) { foreach (KeyValuePair> recordPair in entry.Fields) { if (!this.TryParseTokenString(recordPair.Key, tokenContext, migrator, out string keyError, out TokenString key)) return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} is invalid: {keyError}."); foreach (var fieldPair in recordPair.Value) { int field = fieldPair.Key; if (!this.TryParseTokenString(fieldPair.Value, tokenContext, migrator, out string valueError, out TokenString value)) return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} > field {field} is invalid: {valueError}."); if (value.Raw?.Contains("/") == true) return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} > field {field} is invalid: value can't contain field delimiter character '/'."); fields.Add(new EditDataPatchField(key, field, value)); } } } // save patch = new EditDataPatch(entry.LogName, pack, assetName, conditions, entries, fields, this.Monitor, this.Helper.Content.NormaliseAssetName); } break; // edit image case PatchType.EditImage: { // read patch mode PatchMode patchMode = PatchMode.Replace; if (!string.IsNullOrWhiteSpace(entry.PatchMode) && !Enum.TryParse(entry.PatchMode, true, out patchMode)) return TrackSkip($"the {nameof(PatchConfig.PatchMode)} is invalid. Expected one of these values: [{string.Join(", ", Enum.GetNames(typeof(PatchMode)))}]."); // save if (!this.TryPrepareLocalAsset(pack, entry.FromFile, tokenContext, migrator, out string error, out TokenString fromAsset)) return TrackSkip(error); patch = new EditImagePatch(entry.LogName, pack, assetName, conditions, fromAsset, entry.FromArea, entry.ToArea, patchMode, this.Monitor, this.Helper.Content.NormaliseAssetName); } break; default: return TrackSkip($"unsupported patch type '{action}'."); } // skip if not enabled // note: we process the patch even if it's disabled, so any errors are caught by the modder instead of only failing after the patch is enabled. if (!enabled) return TrackSkip($"{nameof(PatchConfig.Enabled)} is false.", warn: false); // save patch this.PatchManager.Add(patch); return true; } catch (Exception ex) { return TrackSkip($"error reading info. Technical details:\n{ex}"); } } /// Normalise and parse the given condition values. /// The raw condition values to normalise. /// The tokens available for this content pack. /// The migrator which validates and migrates content pack data. /// The normalised conditions. /// An error message indicating why normalisation failed. private bool TryParseConditions(InvariantDictionary raw, IContext tokenContext, IMigration migrator, out ConditionDictionary conditions, out string error) { conditions = new ConditionDictionary(); // no conditions if (raw == null || !raw.Any()) { error = null; return true; } // parse conditions Lexer lexer = new Lexer(); foreach (KeyValuePair pair in raw) { // parse condition key ILexToken[] lexTokens = lexer.ParseBits(pair.Key, impliedBraces: true).ToArray(); if (lexTokens.Length != 1 || !(lexTokens[0] is LexTokenToken lexToken) || lexToken.PipedTokens.Any()) { error = $"'{pair.Key}' isn't a valid token name"; conditions = null; return false; } TokenName name = new TokenName(lexToken.Name, lexToken.InputArg?.Text); // apply migrations if (!migrator.TryMigrate(ref name, out error)) { conditions = null; return false; } // get token IToken token = tokenContext.GetToken(name, enforceContext: false); if (token == null) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", tokenContext.GetTokens(enforceContext: false).Select(p => p.Name).OrderBy(p => p))}"; conditions = null; return false; } // validate subkeys if (!token.CanHaveSubkeys) { if (name.HasSubkey()) { error = $"{name.Key} conditions don't allow subkeys (:)"; conditions = null; return false; } } else if (token.RequiresSubkeys) { if (!name.HasSubkey()) { error = $"{name.Key} conditions must specify a token subkey (see readme for usage)"; conditions = null; return false; } } // parse values InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); if (!values.Any()) { error = $"{name} can't be empty"; conditions = null; return false; } // validate token keys & values if (!token.TryValidate(name, values, out string customError)) { error = $"invalid {name} condition: {customError}"; conditions = null; return false; } // create condition conditions[name] = new Condition(name, values); } // return parsed conditions error = null; return true; } /// Parse a comma-delimited set of case-insensitive condition values. /// The field value to parse. public InvariantHashSet ParseCommaDelimitedField(string field) { if (string.IsNullOrWhiteSpace(field)) return new InvariantHashSet(); IEnumerable values = ( from value in field.Split(',') where !string.IsNullOrWhiteSpace(value) select value.Trim() ); return new InvariantHashSet(values); } /// Parse a boolean value from a string which can contain tokens, and validate that it's valid. /// The raw string which may contain tokens. /// The tokens available for this content pack. /// The migrator which validates and migrates content pack data. /// An error phrase indicating why parsing failed (if applicable). /// The parsed value. private bool TryParseEnabled(string rawValue, IContext tokenContext, IMigration migrator, out string error, out bool parsed) { parsed = false; // analyse string if (!this.TryParseTokenString(rawValue, tokenContext, migrator, out error, out TokenString tokenString)) return false; // validate & extract tokens string text = rawValue; if (tokenString.HasAnyTokens) { // only one token allowed if (!tokenString.IsSingleTokenOnly) { error = "can't be treated as a true/false value because it contains multiple tokens."; return false; } // check token options TokenName tokenName = tokenString.Tokens.First(); IToken token = tokenContext.GetToken(tokenName, enforceContext: false); InvariantHashSet allowedValues = token?.GetAllowedValues(tokenName); if (token == null || token.IsMutable || !token.IsValidInContext) { error = $"can only use static tokens in this field, consider using a {nameof(PatchConfig.When)} condition instead."; return false; } if (allowedValues == null || !allowedValues.All(p => bool.TryParse(p, out _))) { error = "that token isn't restricted to 'true' or 'false'."; return false; } if (token.CanHaveMultipleValues(tokenName)) { error = "can't be treated as a true/false value because that token can have multiple values."; return false; } text = token.GetValues(tokenName).First(); } // parse text if (!bool.TryParse(text, out parsed)) { error = $"can't parse {tokenString.Raw} as a true/false value."; return false; } return true; } /// Parse a string which can contain tokens, and validate that it's valid. /// The raw string which may contain tokens. /// The tokens available for this content pack. /// The migrator which validates and migrates content pack data. /// An error phrase indicating why parsing failed (if applicable). /// The parsed value. private bool TryParseTokenString(string rawValue, IContext tokenContext, IMigration migrator, out string error, out TokenString parsed) { // parse parsed = new TokenString(rawValue, tokenContext); if (!migrator.TryMigrate(ref parsed, out error)) return false; // validate unknown tokens if (parsed.InvalidTokens.Any()) { error = $"found unknown tokens ({string.Join(", ", parsed.InvalidTokens.OrderBy(p => p))})"; parsed = null; return false; } // validate tokens foreach (TokenName tokenName in parsed.Tokens) { IToken token = tokenContext.GetToken(tokenName, enforceContext: false); if (token == null) { error = $"{{{{{tokenName}}}}} can't be used as a token because that token could not be found."; // should never happen parsed = null; return false; } if (token.CanHaveMultipleValues(tokenName)) { error = $"{{{{{tokenName}}}}} can't be used as a token because it can have multiple values."; parsed = null; return false; } } // looks OK error = null; return true; } /// Prepare a local asset file for a patch to use. /// The content pack being loaded. /// The asset path in the content patch. /// The tokens available for this content pack. /// The migrator which validates and migrates content pack data. /// The error reason if preparing the asset fails. /// The parsed value. /// Returns whether the local asset was successfully prepared. private bool TryPrepareLocalAsset(ManagedContentPack pack, string path, IContext tokenContext, IMigration migrator, out string error, out TokenString tokenedPath) { // normalise raw value path = this.NormaliseLocalAssetPath(pack, path); if (path == null) { error = $"must set the {nameof(PatchConfig.FromFile)} field for this action type."; tokenedPath = null; return false; } // tokenise if (!this.TryParseTokenString(path, tokenContext, migrator, out string tokenError, out tokenedPath)) { error = $"the {nameof(PatchConfig.FromFile)} is invalid: {tokenError}"; tokenedPath = null; return false; } // looks OK error = null; return true; } /// Get a normalised file path relative to the content pack folder. /// The content pack. /// The relative asset path. private string NormaliseLocalAssetPath(ManagedContentPack contentPack, string path) { // normalise asset name if (string.IsNullOrWhiteSpace(path)) return null; string newPath = this.Helper.Content.NormaliseAssetName(path); // add .xnb extension if needed (it's stripped from asset names) string fullPath = contentPack.GetFullPath(newPath); if (!File.Exists(fullPath)) { if (File.Exists($"{fullPath}.xnb") || Path.GetExtension(path) == ".xnb") newPath += ".xnb"; } return newPath; } } }