using HavenSoft.HexManiac.Core.Models.Runs; using HavenSoft.HexManiac.Core.Models.Runs.Factory; using HavenSoft.HexManiac.Core.ViewModels; using HavenSoft.HexManiac.Core.ViewModels.DataFormats; using HavenSoft.HexManiac.Core.ViewModels.Tools; using Microsoft.Scripting.Utils; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; // TODO consolidate script length logic: // ScriptExtensions.GetScriptSegmentLength // ScriptParser.CollectScripts // ScriptParser.FindLength namespace HavenSoft.HexManiac.Core.Models.Code { public record ScriptErrorInfo(string Message, TextSegment Segment); public class ScriptParser { public const int MaxRepeates = 20; private readonly IReadOnlyList engine; private readonly byte endToken; private int gameHash; public bool RequireCompleteAddresses { get; set; } = true; public event EventHandler CompileError; public ScriptParser(int gameHash, IReadOnlyList engine, byte endToken) { (this.engine, this.endToken) = (engine, endToken); this.gameHash = gameHash; } public void RefreshGameHash(IDataModel model) => gameHash = model.GetShortGameCode(); public int GetScriptSegmentLength(IDataModel model, int address) => engine.GetScriptSegmentLength(gameHash, model, address, new Dictionary()); public string Parse(IDataModel data, int start, int length) { var builder = new StringBuilder(); foreach (var line in Decompile(data, start, length)) builder.AppendLine(line); return builder.ToString(); } public List CollectScripts(IDataModel model, int address) { // do some basic validation to make sure this is actually a reasonable thing to decode var currentRun = model.GetNextRun(address); var scripts = new List(); if (address < 0 || address >= model.Count) return scripts; if (currentRun.Start < address) return scripts; if (currentRun.Start == address && !(currentRun is IScriptStartRun) && !(currentRun is NoInfoRun)) return scripts; scripts.Add(address); var lengths = new List(); for (int i = 0; i < scripts.Count; i++) { while (i < scripts.Count && i.Range().Any(j => scripts[j] <= scripts[i] && scripts[i] < scripts[j] + lengths[j])) scripts.RemoveAt(i); if (i == scripts.Count) break; address = scripts[i]; int length = 0; var destinations = new Dictionary(); int lastCommand = -1, repeateLength = 0; while (true) { var line = engine.GetMatchingLine(gameHash, model, address + length); if (line == null) break; // normally we would just use this, // but we want to track all the pointers that go to scripts separately. // so just use this to get the child-sizes, so we can adjust the length after line.CompiledByteLength(model, address + length, destinations); length += line.LineCode.Count; foreach (var arg in line.Args) { if (arg.Type == ArgType.Pointer) { var destination = model.ReadPointer(address + length); if (destination >= 0 && destination < model.Count && arg.PointerType == ExpectedPointerType.Script && !scripts.Contains(destination) && i.Range().All(j => destination < scripts[j] || scripts[j] + lengths[j] <= destination) ) { scripts.Add(destination); } } length += arg.Length(model, address + length); } if (line.IsEndingCommand) break; if (line.LineCode[0] != lastCommand) (lastCommand, repeateLength) = (line.LineCode[0], 1); else repeateLength += 1; if (line.Args.Count > 0) repeateLength = 0; if (repeateLength > MaxRepeates) break; // same command lots of times in a row is fishy } // append child scripts that come directly after this script while (true) { // child script starts directly after this script and has only one source if (destinations.TryGetValue(address + length, out int childLength) && childLength > 0) { var anchor = model.GetNextAnchor(address + length); if (anchor.Start == address + length && anchor.PointerSources.Count == 1) { length += childLength; continue; } } // child script has a 1-byte margin (probably an end after a goto) and has only one source if (destinations.TryGetValue(address + length + 1, out childLength)) { // there was a skip... should we ignore it? // If something points to that position, we can't keep going. var anchor = model.GetNextAnchor(address + length); if (anchor.Start == address + length && anchor.PointerSources.Count > 0) break; anchor = model.GetNextAnchor(address + length + 1); if (anchor.Start == address + length + 1 && anchor.PointerSources.Count == 1) { length += childLength + 1; continue; } } break; } lengths.Add(length); for (int j = lengths.Count - 1; j >= 0; j--) { if (scripts[j] <= address || scripts[j] >= address + length) continue; lengths.RemoveAt(j); scripts.RemoveAt(j); i--; } // look for other scripts from destinations that we should care about but don't yet foreach (var d in destinations.Keys) { if (scripts.Contains(d)) continue; // destination is already in queue if (model.GetNextRun(d) is not IScriptStartRun scriptStart || scriptStart.Start != d) continue; // destination isn't formatted as a script if (lengths.Count().Range().Any(j => scripts[j] <= d && d < scripts[j] + lengths[j])) continue; // destination is included within exsting script scripts.Add(d); } } return scripts; } public int FindLength(IDataModel model, int address, Dictionary destinations = null) { if (destinations == null) destinations = model.CurrentCacheScope.ScriptDestinations(address); int length = 0; int consecutiveNops = 0; int lastCommand = -1, repeateLength = 1; while (true) { var line = engine.GetMatchingLine(gameHash, model, address + length); if (line == null) break; consecutiveNops = line.LineCommand.StartsWith("nop") ? consecutiveNops + 1 : 0; if (consecutiveNops > 16) return 0; length += line.CompiledByteLength(model, address + length, destinations); if (line.IsEndingCommand) break; if (line.LineCode[0] != lastCommand) (lastCommand, repeateLength) = (line.LineCode[0], 1); else repeateLength += 1; if (line.Args.Count > 0) repeateLength = 0; if (repeateLength > ScriptParser.MaxRepeates) break; // same command lots of times in a row is fishy } // Include in the length any content that comes directly (or +1) after the script. // This content should be considered part of the script. // Only do this if the content is only referenced once, otherwise it may not be safe to include during repoints. while (true) { if (destinations.TryGetValue(address + length, out int additionalLength) && additionalLength > 0) { var anchor = model.GetNextAnchor(address + length); if (anchor.Start == address + length && anchor.PointerSources.Count == 1) { length += additionalLength; continue; } } if (destinations.TryGetValue(address + length + 1, out additionalLength)) { // there was a skip... should we ignore it? // If something points to that position, we can't keep going. var anchor = model.GetNextAnchor(address + length); if (anchor.Start == address + length && anchor.PointerSources.Count > 0) break; anchor = model.GetNextAnchor(address + length + 1); if (anchor.Start == address + length + 1 && anchor.PointerSources.Count == 1) { length += additionalLength + 1; continue; } } break; } return length; } public MacroScriptLine GetMacro(IDataModel model, int address) => engine.GetMatchingMacro(gameHash, model, address); public ScriptLine GetLine(IDataModel model, int address) => engine.GetMatchingLine(gameHash, model, address); public IEnumerable DependsOn(string basename) { foreach (var line in engine) { foreach (var arg in line.Args) { if (arg.EnumTableName == basename) { yield return line; break; } } } } private HashSet constantCache, keywordCache; public void AddKeywords(IDataModel model, CodeBody body) { var editor = body.Editor; editor.LineCommentHeader = "#"; editor.MultiLineCommentHeader = "/*"; editor.MultiLineCommentFooter = "*/"; if (constantCache == null || keywordCache == null) { constantCache = new HashSet(); keywordCache = new HashSet(); foreach (var line in engine) { keywordCache.Add(line.LineCommand); foreach (var arg in line.Args) { if (string.IsNullOrEmpty(arg.EnumTableName)) continue; foreach (var option in model.GetOptions(arg.EnumTableName)) { if (option == null) continue; if ("<>!=?".Any(option.Contains)) continue; constantCache.Add(option); } } } } editor.Constants.Clear(); editor.Constants.AddRange(constantCache); editor.Keywords.Clear(); editor.Keywords.AddRange(keywordCache); editor.Keywords.Add("auto"); // for the auto-pointer feature } public void ClearConstantCache() { constantCache = null; } // TODO refactor to rely on CollectScripts rather than duplicate code // returns a list of scripts that were formatted, and their length public IDictionary FormatScript(ModelDelta token, IDataModel model, int address) where SERun : IScriptStartRun { Func, IScriptStartRun> constructor = (a, s) => new XSERun(a, s); if (typeof(SERun) == typeof(BSERun)) constructor = (a, s) => new BSERun(a, s); if (typeof(SERun) == typeof(ASERun)) constructor = (a, s) => new ASERun(a, s); if (typeof(SERun) == typeof(TSERun)) constructor = (a, s) => new TSERun(a, s); var processed = new Dictionary(); var toProcess = new List { address }; while (toProcess.Count > 0) { address = toProcess.Last(); toProcess.RemoveAt(toProcess.Count - 1); var existingRun = model.GetNextRun(address); // We need to check for an anchor here _before_ checking if this is a duplicate address and therefore skippable. // Because self-referential scripts won't have their anchor picked up on the initial run. if (!(existingRun is SERun) && existingRun.Start == address) { var anchorName = model.GetAnchorFromAddress(-1, address); model.ObserveAnchorWritten(token, anchorName, constructor(address, existingRun.PointerSources)); } if (processed.ContainsKey(address)) continue; int length = 0; while (true) { var line = engine.GetMatchingLine(gameHash, model, address + length); if (line == null) break; // there may've previously been a pointer here: the code has changed! if (model.GetNextRun(address + length) is PointerRun pRun && pRun.Start == address + length) model.ClearFormat(token, address + length, line.LineCode.Count); length += line.LineCode.Count; foreach (var arg in line.Args) { if (arg.Type != ArgType.Pointer) { // there may've previously been a pointer here: the code has changed! // when clearing args, we actually _do_ want to clear any anchors that point to them. var start = address + length; var argLength = arg.Length(model, start); if (model.GetNextRun(start) is ITableRun tableRun && tableRun.Start == start && tableRun.Length == argLength) { // no need to clear } else { model.ClearFormat(token, start, argLength); } } else { var destination = model.ReadPointer(address + length); if (destination.InRange(0, model.Count) && !destination.InRange(address + length, address + length + 4)) { var destinationRun = model.GetNextRun(destination); if (destinationRun.Start < destination) { // we're trying to point into already formatted data // don't add the pointer source or destination } else { var pointerSource = model.GetNextRun(address + length); if (pointerSource is PointerRun pointerRun && pointerRun.Start == address + length) { // no need to clear/update } else { model.ClearFormat(token, address + length, 4); model.ObserveRunWritten(token, new PointerRun(address + length)); } if (arg.PointerType == ExpectedPointerType.Script) toProcess.Add(destination); if (arg.PointerType == ExpectedPointerType.Text) { WriteTextStream(model, token, destination, address + length); } else if (arg.PointerType == ExpectedPointerType.Movement) { WriteMovementStream(model, token, destination, address + length); } else if (arg.PointerType == ExpectedPointerType.Mart) { WriteMartStream(model, token, destination, address + length); } else if (arg.PointerType == ExpectedPointerType.Decor) { WriteDecorStream(model, token, destination, address + length); } else if (arg.PointerType == ExpectedPointerType.SpriteTemplate) { WriteSpriteTemplateStream(model, token, destination, address + length); } } } } length += arg.Length(model, address + length); } if (line.IsEndingCommand) break; } processed.Add(address, length); } return processed; } private void WriteTextStream(IDataModel model, ModelDelta token, int destination, int source) { if (destination < 0 || destination > model.Count) return; var destinationLength = PCSString.ReadString(model, destination, true); if (destinationLength > 0) { // if there's an anchor that starts exactly here, we don't want to clear it: just update it // if the run starts somewhere else, we better to a clear to prevent conflicting formats var existingTextRun = model.GetNextRun(destination); if (existingTextRun.Start != destination) { model.ClearFormat(token, destination, destinationLength); model.ObserveRunWritten(token, new PCSRun(model, destination, destinationLength, SortedSpan.One(source))); } else if (existingTextRun is PCSRun && destinationLength == existingTextRun.Length) { // length and format is correct, nothing to do } else { model.ClearAnchor(token, destination + existingTextRun.Length, destinationLength - existingTextRun.Length); // assuming that the old run ends before the new run, clear the difference model.ObserveRunWritten(token, new PCSRun(model, destination, destinationLength, SortedSpan.One(source))); model.ClearAnchor(token, destination + destinationLength, existingTextRun.Length - destinationLength); // assuming that the new run ends before the old run, clear the difference } } } private void WriteMovementStream(IDataModel model, ModelDelta token, int start, int source) { var format = "[move.movementtypes]!FE"; WriteStream(model, token, start, source, format); } public void WriteMartStream(IDataModel model, ModelDelta token, int start, int source) { var format = $"[item:{HardcodeTablesModel.ItemsTableName}]!0000"; WriteStream(model, token, start, source, format); } public void WriteDecorStream(IDataModel model, ModelDelta token, int start, int source) { var format = $"[item:{HardcodeTablesModel.DecorationsTableName}]!0000"; WriteStream(model, token, start, source, format); } private void WriteSpriteTemplateStream(IDataModel model, ModelDelta token, int start, int source) { var format = "[tileTag: paletteTag: oam<> anims<> images<> affineAnims<> callback<>]1"; WriteStream(model, token, start, source, format); } private void WriteStream(IDataModel model, ModelDelta token, int start, int source, string format) { var sources = source >= 0 ? SortedSpan.One(source) : SortedSpan.None; TableStreamRun.TryParseTableStream(model, start, sources, string.Empty, format, null, out var tsRun); if (tsRun != null) { var endTokenLength = tsRun.Length - tsRun.ElementLength * tsRun.ElementCount; if (endTokenLength > 0 && model.IsFreespace(start, endTokenLength) && token is not NoDataChangeDeltaModel) { // freespace: write the end token model.ClearFormat(token, tsRun.Start, endTokenLength); tsRun = tsRun.DeserializeRunFromZero(string.Empty, token, out var _, out var _); model.ObserveRunWritten(token, tsRun); } else if (model.GetNextRun(tsRun.Start) is ITableRun existingRun && existingRun.Start == tsRun.Start && tsRun.DataFormatMatches(existingRun)) { // no need to update the format, the format already matches what we want } else { model.ClearFormat(token, tsRun.Start, tsRun.Length); if (tsRun.ElementCount == 0) { // write the end token tsRun.DeserializeRun(string.Empty, token, out var _, out var _); } model.ObserveRunWritten(token, tsRun); } } } private record StreamInfo(ExpectedPointerType PointerType, int Source, int Destination); public static int InsertMissingClosers(ref string script, int caret) { int caretMove = 0; bool isStartOfLine = true; var text = script.ToList(); for (int i = 0; i < text.Count; i++) { if (text[i] == '\n' || text[i] == '\r') isStartOfLine = true; else if (text[i] != ' ' && text[i] != '{') isStartOfLine = false; if (text[i] != '{') continue; if (!isStartOfLine) { text.Insert(i, '\r'); text.Insert(i + 1, '\n'); if (i <= caret) { caret += 2; } i += 2; } isStartOfLine = true; var close = -1; for (int j = i + 1; j < text.Count && text[j] != '{'; j++) { if (text[j] == '\n' || text[j] == '\r') isStartOfLine = true; else if (text[j] != ' ' && text[j] != '}') isStartOfLine = false; if (text[j] != '}') continue; if (!isStartOfLine) { text.Insert(j, '\r'); text.Insert(j + 1, '\n'); if (j < caret) { caret += 2; caretMove += 2; } j += 2; } close = j; break; } if (close == -1) { if (i == caret) { text.Insert(i + 1, '\r'); text.Insert(i + 2, '\n'); } text.Insert(i + 3, '}'); close = i + 3; if (i == caret) caretMove -= 3; // check for excess blank lines after the lines we just inserted if (close < text.Count - 4 && text[close + 1] == '\r' && text[close + 2] == '\n' && text[close + 3] == '\r' && text[close + 4] == '\n') { text.RemoveAt(close + 1); text.RemoveAt(close + 1); } } if (i + 1 < text.Count && text[i + 1] != '\r') { text.Insert(i + 1, '\r'); text.Insert(i + 2, '\n'); if (i < caret) { caret += 2; caretMove += 2; } close += 2; } if (close + 1 < text.Count && text[close + 1] != '\r') { text.Insert(close + 1, '\r'); text.Insert(close + 2, '\n'); if (close < caret) { caret += 2; } isStartOfLine = true; close += 2; } // special case: no blank line between open and close if (close == i + 3) { text.Insert(i + 1, '\r'); text.Insert(i + 2, '\n'); if (i < caret) { caret += 2; caretMove -= 2; } close += 2; } i = close; } script = new string(text.ToArray()); return caretMove; } public byte[] Compile(ModelDelta token, IDataModel model, int start, ref string script, out IReadOnlyList<(int originalLocation, int newLocation)> movedData, out int ignoreCharacterCount) { int ignoreCaret = 0; return Compile(token, model, start, ref script, ref ignoreCaret, out movedData, out ignoreCharacterCount); } /// /// Potentially edits the script text and returns a set of data repoints. /// The data is moved, but the script itself has not written by this method. /// /// Related data runs that moved during compilation. /// Number of new characters added that should be ignored by the caret. /// public byte[] Compile(ModelDelta token, IDataModel model, int start, ref string script, ref int caret, out IReadOnlyList<(int originalLocation, int newLocation)> movedData, out int ignoreCharacterCount) { ignoreCharacterCount = 0; movedData = new List<(int, int)>(); var deferredContent = new List(); int adjustCaret = InsertMissingClosers(ref script, caret); caret += adjustCaret; var lines = script.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None) .Select(line => line.Split('#').First()) .ToArray(); var result = new List(); var streamInfo = new List(); var labels = ExtractLocalLabels(model, start, lines); bool lastCommandIsEndCommand = false; for (var i = 0; i < lines.Length; i++) { if (string.IsNullOrWhiteSpace(lines[i])) continue; var line = lines[i].Trim(); if (line.EndsWith(":")) continue; // label, not code. Don't parse. if (line == "{") { var streamStart = i + 1; var indentCount = 1; i += 1; while (indentCount > 0 && lines.Length > i) { line = lines[i].Trim(); if (line == "{") indentCount += 1; if (line == "}") indentCount -= 1; i += 1; } i -= 1; var streamEnd = i; var streamLines = lines.Skip(streamStart).Take(streamEnd - streamStart).ToList(); var stream = Environment.NewLine.Join(streamLines); // Let the stream run handle updating itself based on the stream content. if (streamInfo.Count > 0) { var info = streamInfo[0]; if (info.Destination == DeferredStreamToken.AutoSentinel + Pointer.NULL) { var deferred = deferredContent[deferredContent.Count - streamInfo.Count]; deferred.UpdateContent(model, info.PointerType, stream); } else if (model.GetNextRun(info.Destination) is IStreamRun streamRun && streamRun.Start == info.Destination) { var newStreamRun = streamRun.DeserializeRun(stream, token, out var _, out var _); // we don't notify parents/children based on script-stream changes: we know they never have parents/children. // alter script content and compiled byte location based on stream move if (newStreamRun.Start != info.Destination) { script = script.Replace(info.Destination.ToAddress(), newStreamRun.Start.ToAddress()); result[info.Source - start + 0] = (byte)(newStreamRun.Start >> 0); result[info.Source - start + 1] = (byte)(newStreamRun.Start >> 8); result[info.Source - start + 2] = (byte)(newStreamRun.Start >> 16); result[info.Source - start + 3] = (byte)((newStreamRun.Start >> 24) + 0x08); ((List<(int, int)>)movedData).Add((info.Destination, newStreamRun.Start)); } if (newStreamRun != streamRun) model.ObserveRunWritten(token, newStreamRun); } streamInfo.RemoveAt(0); } continue; } streamInfo.Clear(); var command = FirstMatch(line); if (command != null) { var currentSize = result.Count; if (line.Contains("")) { int newAddress = -1; if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.Movement)) { if (model.FreeSpaceStart == start) model.FreeSpaceStart += model.FreeSpaceBuffer; newAddress = model.FindFreeSpace(0, 0x10); token.ChangeData(model, newAddress, 0xFE); WriteMovementStream(model, token, newAddress, -1); } else if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.Text)) { newAddress = model.FindFreeSpace(0, 0x10); if (newAddress == -1) { var endLength = model.Count; model.ExpandData(token, endLength + 0x10); newAddress = endLength + 0x10; } token.ChangeData(model, newAddress, 0xFF); model.ObserveRunWritten(token, new PCSRun(model, newAddress, 1)); } else if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.Mart)) { newAddress = model.FindFreeSpace(0, 0x10); token.ChangeData(model, newAddress, 0x00); token.ChangeData(model, newAddress + 1, 0x00); WriteMartStream(model, token, newAddress, -1); } else if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.Decor)) { newAddress = model.FindFreeSpace(0, 0x10); token.ChangeData(model, newAddress, 0x00); token.ChangeData(model, newAddress + 1, 0x00); WriteDecorStream(model, token, newAddress, -1); } else if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.SpriteTemplate)) { newAddress = model.FindFreeSpace(0, 0x18); for (int j = 0; j < 0x18; j++) token.ChangeData(model, newAddress + j, 0x00); WriteSpriteTemplateStream(model, token, newAddress, -1); } else if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.Script)) { newAddress = model.FindFreeSpace(0, 0x10); token.ChangeData(model, newAddress, endToken); // TODO write a new script template... an anchor with a format based on the current script } if (newAddress != -1) { var originalLine = line; line = line.ReplaceOne("", $"<{newAddress:X6}>"); if (command.Args.Any(arg => arg.PointerType == ExpectedPointerType.Script)) { script = script.ReplaceOne("", $"<{newAddress:X6}>"); } else if (script.IndexOf(originalLine) != script.IndexOf($"{originalLine}{Environment.NewLine}{{{Environment.NewLine}")) { script = script.ReplaceOne(originalLine, $"{line}{Environment.NewLine}{{{Environment.NewLine}}}"); ignoreCharacterCount += Environment.NewLine.Length * 2 + 2; } else { script = script.ReplaceOne("", $"<{newAddress:X6}>"); } } } var error = command.Compile(model, start + currentSize, line, labels, out var code); if (error == null) { result.AddRange(code); } else { var segment = new TextSegment(i, 0, lines[i].Length, SegmentType.Error); CompileError?.Invoke(this, new(i + ": " + error, segment)); return null; } var pointerOffset = command.LineCode.Count; foreach (var arg in command.Args) { if (arg.Type == ArgType.Pointer && arg.PointerType != ExpectedPointerType.Unknown) { var destination = result.ReadMultiByteValue(currentSize + pointerOffset, 4) + Pointer.NULL; if (destination == DeferredStreamToken.AutoSentinel + Pointer.NULL) { streamInfo.Add(new(arg.PointerType, start + currentSize + pointerOffset, destination)); (string format, byte[] defaultContent) = arg.PointerType switch { ExpectedPointerType.Text => ("\"\"", new byte[] { 0xFF }), ExpectedPointerType.Mart => ($"[item:{HardcodeTablesModel.ItemsTableName}]!0000", new byte[] { 0, 0 }), ExpectedPointerType.Decor => ($"[item:{HardcodeTablesModel.DecorationsTableName}]!0000", new byte[] { 0, 0 }), ExpectedPointerType.Movement => ($"[move.movementtypes]!FE", new byte[] { 0xFE }), _ => ("^", new byte[0]) }; deferredContent.Add(new(currentSize + pointerOffset, format, defaultContent)); } else if (destination >= 0 && arg.PointerType != ExpectedPointerType.Script) { streamInfo.Add(new(arg.PointerType, start + currentSize + pointerOffset, destination)); if (arg.PointerType == ExpectedPointerType.Text) { WriteTextStream(model, token, destination, start + currentSize + pointerOffset); } else if (arg.PointerType == ExpectedPointerType.Movement) { WriteMovementStream(model, token, destination, start + currentSize + pointerOffset); } } } pointerOffset += arg.Length(model, currentSize + pointerOffset); } lastCommandIsEndCommand = command.IsEndingCommand; } } if (!lastCommandIsEndCommand) result.Add(endToken); // any labels that were used but not included, stick them on the end of the script labels.ResolveUnresolvedLabels(start, result, endToken); // done with script lines, now write deferred data foreach (var deferred in deferredContent) { deferred.WriteData(result, start); } return result.ToArray(); } public IScriptLine FirstMatch(string line) { foreach (var command in engine) { if (!command.MatchesGame(gameHash)) continue; if (command.CanCompile(line)) return command; } return null; } private LabelLibrary ExtractLocalLabels(IDataModel model, int start, string[] lines) { var labels = new Dictionary(); var length = 0; foreach (var fullLine in lines) { var line = fullLine.Trim(); if (line.EndsWith(":")) { labels[line.Substring(0, line.Length - 1)] = start + length; continue; } foreach (var command in engine) { if (!command.CanCompile(line)) continue; length += command.CompiledByteLength(model, line); break; } } return new LabelLibrary(model, labels) { RequireCompleteAddresses = RequireCompleteAddresses }; } private List ReadOptions(IDataModel model, string tableName, string token) { if (!string.IsNullOrEmpty(tableName)) { var isList = model.TryGetList(tableName, out var list); var allOptions = model.GetOptions(tableName); var options = new List(); for (int i = 0; i < allOptions.Count; i++) { if (token.Length == 0 || allOptions[i].MatchesPartial(token)) { if (!isList || list.Comments == null || !list.Comments.TryGetValue(i, out var comment)) comment = string.Empty; else comment = " # " + comment; options.Add(allOptions[i] + comment); } } if (token.Length > 0) { options.Sort((a, b) => a.SkipCount(token) - b.SkipCount(token)); options.Sort((a, b) => a.IndexOf(token[0], StringComparison.CurrentCultureIgnoreCase) - b.IndexOf(token[0], StringComparison.CurrentCultureIgnoreCase)); } if (options.Count > 10) { while (options.Count > 9) options.RemoveAt(options.Count - 1); options.Add("..."); } return options; } return null; } public string GetHelp(IDataModel model, HelpContext context) { var currentLine = context.Line; if (string.IsNullOrWhiteSpace(currentLine)) return null; var tokens = ScriptLine.Tokenize(currentLine.Trim()); var candidates = engine.Where(line => line.LineCommand.Contains(tokens[0], StringComparison.CurrentCultureIgnoreCase)).ToList(); var isAfterToken = context.Index > 0 && (context.Line.Length == context.Index || context.Line[context.Index] == ' ') && (char.IsLetterOrDigit(context.Line[context.Index - 1]) || context.Line[context.Index - 1].IsAny("_~'\"-.".ToCharArray())); if (isAfterToken) { tokens = ScriptLine.Tokenize(currentLine.Substring(0, context.Index).Trim()); // try to auto-complete whatever token is left of the cursor // need autocomplete for command? if (tokens.Length == 1) { candidates = candidates.Where(line => line.LineCommand.Contains(tokens[0], StringComparison.CurrentCultureIgnoreCase) && line.MatchesGame(gameHash)).ToList(); if (!context.IsSelection) { foreach (var line in candidates) { if (line.LineCommand == tokens[0] && line.CountShowArgs() == 0) return null; // perfect match with no args } } return Environment.NewLine.Join(candidates.Take(10).Select(line => line.Usage)); } // filter down to just perfect matches. There could be several (trainerbattle) candidates = candidates.Where(line => line.LineCommand.Equals(tokens[0], StringComparison.CurrentCultureIgnoreCase)).ToList(); var checkToken = 1; while (candidates.Count > 1 && checkToken < tokens.Length) { if (!tokens[checkToken].TryParseHex(out var codeValue)) break; candidates = candidates.Where(line => line.LineCode.Count <= checkToken || line.LineCode[checkToken] == codeValue).ToList(); checkToken++; } var syntax = candidates.FirstOrDefault(); if (syntax != null) { var args = syntax.Args.Where(arg => arg is ScriptArg).ToList(); var skipCount = syntax.LineCode.Count; if (skipCount == 0) skipCount = 1; // macros if (args.Count + skipCount >= tokens.Length && tokens.Length >= skipCount + 1) { var arg = args[tokens.Length - 1 - skipCount]; var token = tokens[tokens.Length - 1]; var options = ReadOptions(model, arg.EnumTableName, token); if (options != null) { if (args.Count == tokens.Length - skipCount && options.Any(option => option == token)) return null; // perfect match on last token return Environment.NewLine.Join(options); } } } } if (candidates.Count > 10) return null; if (candidates.Count == 0) return null; string partialDocumentation = string.Empty; if (candidates.Count == 1 && !candidates[0].Documentation.All(string.IsNullOrWhiteSpace)) { if (candidates[0].CountShowArgs() <= tokens.Length - 1) return null; partialDocumentation = candidates[0].Usage + Environment.NewLine + string.Join(Environment.NewLine, candidates[0].Documentation); } if (context.Index > 0 && context.Line[context.Index - 1] == ' ' && tokens.Length > 0) { // I'm directly after a space, I'm at the start of a new token, there is no existing documentation var skipCount = candidates[0].LineCode.Count; if (skipCount == 0) skipCount = 1; var args = candidates[0].Args.Where(arg => arg is ScriptArg).ToList(); if ((tokens.Length - skipCount).InRange(0, args.Count)) { var arg = args[tokens.Length - skipCount]; var options = ReadOptions(model, arg.EnumTableName, string.Empty); if (options != null) { if (partialDocumentation.Length > 0) partialDocumentation += Environment.NewLine; partialDocumentation += Environment.NewLine.Join(options); } } } if (partialDocumentation.Length > 0) return partialDocumentation; var perfectMatch = candidates.FirstOrDefault(candidate => (currentLine + " ").StartsWith(candidate.LineCommand + " ")); if (perfectMatch != null) { if (perfectMatch.CountShowArgs() == tokens.Length - 1) return null; return perfectMatch.Usage + Environment.NewLine + string.Join(Environment.NewLine, perfectMatch.Documentation); } var bestMatch = candidates.FirstOrDefault(candidate => tokens[0].Contains(candidate.LineCommand)); if (bestMatch != null) { if (bestMatch.CountShowArgs() == tokens.Length - 1) return null; return bestMatch.Usage + Environment.NewLine + Environment.NewLine.Join(bestMatch.Documentation); } return string.Join(Environment.NewLine, candidates.Select(line => line.Usage)); } public static int GetArgLength(IDataModel model, IScriptArg arg, int start, IDictionary destinationLengths) { if (arg.Type == ArgType.Pointer && arg.PointerType != ExpectedPointerType.Unknown) { var destination = model.ReadPointer(start); if (destination >= 0 && destination < model.Count) { var run = model.GetNextRun(destination); if (run is IScriptStartRun scriptStart && scriptStart.Start == destination && scriptStart.Start > start) { return model.GetScriptLength(scriptStart, destinationLengths); } else if (run.Start == destination) { // we only want to add this run's length as part of the script if: // (1) the run has no name // (2) the run has only one source (the script) if (run is NoInfoRun || run.PointerSources == null) return -1; if (run is IScriptStartRun) return -1; // this script has a length, but don't track it (prevent recursion loop) if (run.PointerSources.Count == 1 && string.IsNullOrEmpty(model.GetAnchorFromAddress(-1, destination))) { return run.Length; } } else if (arg.PointerType == ExpectedPointerType.Text) { // we didn't find a matching run, but this data claims to be simple text var textLength = PCSString.ReadString(model, destination, true); return textLength; } } } return -1; } private string[] Decompile(IDataModel data, int index, int length) { var results = new List(); var nextAnchor = data.GetNextAnchor(index); var destinations = new Dictionary(); ISet linesWithLabelsToUpdate = new HashSet(); var labels = new DecompileLabelLibrary(data, index, length); while (length > 0) { if (index == nextAnchor.Start) { if (nextAnchor is IScriptStartRun) { if (results.Count > 0) results.Add(string.Empty); results.Add($"{labels.AddressToLabel(nextAnchor.Start, true)}: # {nextAnchor.Start:X6}"); linesWithLabelsToUpdate.Add(results.Count - 1); } else if (nextAnchor is IStreamRun) { if (destinations.ContainsKey(index)) { index += nextAnchor.Length; length -= nextAnchor.Length; } } nextAnchor = data.GetNextAnchor(nextAnchor.Start + nextAnchor.Length); continue; } else if (index > nextAnchor.Start) { nextAnchor = data.GetNextAnchor(nextAnchor.Start + nextAnchor.Length); continue; } var line = engine.FirstOrDefault(option => option.Matches(gameHash, data, index)); if (line == null) { results.Add($".raw {data[index]:X2}"); index += 1; length -= 1; } else { results.Add(" " + line.Decompile(data, index, labels)); if (line.Args.Any(arg => arg.Type == ArgType.Pointer && arg.PointerType == ExpectedPointerType.Script)) { linesWithLabelsToUpdate.Add(results.Count - 1); } var compiledByteLength = line.CompiledByteLength(data, index, destinations); index += compiledByteLength; length -= compiledByteLength; // if we point to shortly after, keep going if (destinations.ContainsKey(index) && nextAnchor is IStreamRun && nextAnchor.Start == index) continue; if (destinations.ContainsKey(index + 1) && nextAnchor is IStreamRun && nextAnchor.Start == index + 1) continue; if (destinations.ContainsKey(index) && nextAnchor is IScriptStartRun && nextAnchor.Start == index) continue; if (destinations.ContainsKey(index + 1) && nextAnchor is IScriptStartRun && nextAnchor.Start == index + 1) continue; // if we're at an end command, don't keep going if (line.IsEndingCommand) break; } } // post processing: if a line has a stream pointer to within the script, // change that pointer to be an -auto- pointer foreach (var label in labels.AutoLabels.ToList()) { var autoIndex = results.Count.Range().FirstOrDefault(i => results[i].Contains($"<{label:X6}>")); var autoRun = data.GetNextRun(label); var runIsStream = autoRun is IStreamRun; if (runIsStream) { results[autoIndex] = results[autoIndex].Replace($"<{label:X6}>", ""); } else { continue; } } // post processing: change the section labels to be in address order var sections = labels.FinalizeLabels(); foreach (int i in linesWithLabelsToUpdate) { results[i] = labels.FinalizeLine(sections, results[i]); } return results.ToArray(); } } public interface IScriptLine { IReadOnlyList Args { get; } IReadOnlyList LineCode { get; } string LineCommand { get; } IReadOnlyList Documentation { get; } string Usage { get; } bool IsEndingCommand { get; } bool MatchesGame(int gameCodeHash); int CompiledByteLength(IDataModel model, int start, IDictionary destinationLengths); // compile from the bytes in the model, at that start location int CompiledByteLength(IDataModel model, string line); // compile from the line of code passed in bool Matches(int gameCodeHash, IReadOnlyList data, int index); string Decompile(IDataModel data, int start, DecompileLabelLibrary labels); /// /// Returns true if the command looks correct, even if the arguments are incomplete. /// bool CanCompile(string line); /// /// Returns an error if the line cannot be compiled, or a set of tokens if it can be compiled. /// string ErrorCheck(string scriptLine, out string[] tokens); string Compile(IDataModel model, int start, string scriptLine, LabelLibrary labels, out byte[] result); void AddDocumentation(string content); public int CountShowArgs() { return Args.Sum(arg => { if (arg is ScriptArg) return 1; return 0; // something with array args? }); } } public class MacroScriptLine : IScriptLine { private static readonly IReadOnlyList emptyByteList = new byte[0]; private readonly List documentation = new List(); private bool hasShortForm; private readonly Dictionary shortIndexFromLongIndex = new(); private readonly IReadOnlyList matchingGames; public IReadOnlyList Args { get; } public IReadOnlyList ShortFormArgs { get { if (shortIndexFromLongIndex.Count == 0) { return Args.Where(arg => arg is not SilentMatchArg).ToList(); } var args = new IScriptArg[shortIndexFromLongIndex.Count()]; foreach (var pair in shortIndexFromLongIndex) { args[pair.Value] = Args[pair.Key]; } return args; } } public IReadOnlyList LineCode => emptyByteList; public IReadOnlyList Documentation => documentation; public string LineCommand { get; } public bool IsEndingCommand => false; public bool IsValid { get; } = true; public string Usage { get; private set; } public static bool IsMacroLine(string engineLine) { engineLine = engineLine.Trim(); var tokens = engineLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (tokens.Length == 0) return false; var token = tokens[0]; if (token.StartsWith("[") && tokens.Length > 1) token = tokens[1]; if (token.StartsWith("#")) return false; if (token.Length == 2 && token.TryParseHex(out _)) return false; return true; } public MacroScriptLine(string engineLine) { var docSplit = engineLine.Split(new[] { '#' }, 2); if (docSplit.Length > 1) documentation.Add('#' + docSplit[1]); engineLine = docSplit[0].Trim(); matchingGames = ScriptLine.ExtractMatchingGames(ref engineLine); ExtractShortformInfo(ref engineLine); if (!hasShortForm) { Usage = " ".Join(engineLine.Split(' ').Where(token => token.Length != 2 || !token.TryParseHex(out _))); } var tokens = engineLine.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); var args = new List(); LineCommand = tokens[0]; for (int i = 1; i < tokens.Length; i++) { var token = tokens[i]; if (token.Length == 2 && token.TryParseHex(out int number)) { args.Add(new SilentMatchArg((byte)number)); } else if (ScriptArg.IsValidToken(token)) { args.Add(new ScriptArg(token)); } else { IsValid = false; } } Args = args; } private void ExtractShortformInfo(ref string engineLine) { if (!engineLine.Contains("->")) return; var parts = engineLine.Split("->"); if (parts.Length != 2) return; engineLine = parts[1]; var shortTokens = parts[0].Split(' ', StringSplitOptions.RemoveEmptyEntries); var longTokens = parts[1].Split(' ', StringSplitOptions.RemoveEmptyEntries); if (shortTokens[0] != longTokens[0]) return; shortTokens = shortTokens.Skip(1).ToArray(); longTokens = longTokens.Skip(1).ToArray(); // for each entry in long, it shows up somewhere in short // entries in long can appear multiple times for (int i = 0; i < longTokens.Length; i++) { var index = shortTokens.IndexOf(longTokens[i]); if (index == -1) continue; shortIndexFromLongIndex.Add(i, index); } hasShortForm = true; Usage = parts[0]; } public bool MatchesGame(int game) => matchingGames?.Contains(game) ?? true; public int CompiledByteLength(IDataModel model, int start, IDictionary destinationLengths) { var length = LineCode.Count; foreach (var arg in Args) { if (destinationLengths != null) { var argLength = ScriptParser.GetArgLength(model, arg, start + length, destinationLengths); if (argLength > 0) destinationLengths[model.ReadPointer(start + length)] = argLength; } length += arg.Length(default, -1); } return length; } public int CompiledByteLength(IDataModel model, string line) { if (!CanCompile(line)) return 0; var length = LineCode.Count; foreach (var arg in Args) { length += arg.Length(default, -1); } return length; } public bool Matches(int gameCodeHash, IReadOnlyList data, int index) { if (Args.Count == 0) return false; if (!MatchesGame(gameCodeHash)) return false; for (int i = 0; i < Args.Count; i++) { var arg = Args[i]; if (arg is SilentMatchArg smarg) { if (data[index] != smarg.ExpectedValue) return false; } else if (arg is ScriptArg sarg) { // don't validate, this part is variable } else { throw new NotImplementedException(); } index += arg.Length(default, -1); } return true; } public string Decompile(IDataModel data, int start, DecompileLabelLibrary labels) { var builder = new StringBuilder(LineCommand); var streamContent = new List(); var args = new List(); foreach (var arg in Args) { if (arg is ScriptArg sarg) { var tempBuilder = new StringBuilder(); sarg.Build(false, data, start, tempBuilder, streamContent, labels); args.Add(tempBuilder.ToString()); } start += arg.Length(data, start); } if (args.Count > 0) { builder.Append(" "); builder.Append(" ".Join(ConvertLongFormToShortForm(args.ToArray()))); } foreach (var content in streamContent) { builder.AppendLine(); builder.AppendLine("{"); builder.AppendLine(content); builder.Append("}"); } return builder.ToString(); } public bool CanCompile(string line) { var tokens = ScriptLine.Tokenize(line); if (tokens.Length == 0) return false; if (tokens[0] != LineCommand) return false; return true; } public string ErrorCheck(string scriptLine, out string[] tokens) { tokens = ScriptLine.Tokenize(scriptLine); if (tokens[0] != LineCommand) throw new ArgumentException($"Command {LineCommand} was expected, but received {tokens[0]} instead."); var args = tokens.Skip(1).ToArray(); var shortArgs = args; args = ConvertShortFormToLongForm(args); var commandText = LineCommand; var specifiedArgs = Args.Where(arg => arg is ScriptArg).Count(); if (specifiedArgs != args.Length) { return $"Command {commandText} expects {specifiedArgs} arguments, but received {shortArgs.Length} instead."; } return null; } public string Compile(IDataModel model, int start, string scriptLine, LabelLibrary labels, out byte[] result) { result = null; var error = ErrorCheck(scriptLine, out var tokens); if (error != null) return error; var args = tokens.Skip(1).ToArray(); args = ConvertShortFormToLongForm(args); var results = new List(); var specifiedArgIndex = 0; for (int i = 0; i < Args.Count; i++) { if (Args[i] is ScriptArg scriptArg) { var token = args[specifiedArgIndex]; var message = scriptArg.Build(model, start + results.Count, token, results, labels); if (message != null) return message; specifiedArgIndex += 1; } else if (Args[i] is SilentMatchArg silentArg) { results.Add(silentArg.ExpectedValue); } } result = results.ToArray(); return null; } public void AddDocumentation(string doc) => documentation.Add(doc); private string[] ConvertShortFormToLongForm(string[] args) { if (!hasShortForm) return args; // build long-form args from this short form var longForm = new List(); for (int i = 0; i < Args.Count; i++) { if (Args[i] is SilentMatchArg) continue; var shortIndex = shortIndexFromLongIndex[i]; if (shortIndex < args.Length) longForm.Add(args[shortIndex]); } return longForm.ToArray(); } private string[] ConvertLongFormToShortForm(string[] args) { if (!hasShortForm) return args; var shortForm = new Dictionary(); for (int i = 0; i < Args.Count; i++) { if (Args[i] is SilentMatchArg) continue; var shortIndex = shortIndexFromLongIndex[i]; shortForm[shortIndex] = args[shortForm.Count]; } return shortForm.Count.Range(i => shortForm[i]).ToArray(); } } public enum ExpectedPointerType { Unknown, Script, Text, Movement, Mart, Decor, SpriteTemplate, } public abstract class ScriptLine : IScriptLine { private readonly List documentation = new List(); private readonly IReadOnlyList matchingGames; public const string Hex = "0123456789ABCDEF"; public IReadOnlyList Args { get; } public IReadOnlyList LineCode { get; } public string LineCommand { get; } public IReadOnlyList Documentation => documentation; public string Usage { get; } public virtual bool IsEndingCommand { get; } /// If this line contains pointers, calculate the pointer data's lengths and include here. public int CompiledByteLength(IDataModel model, int start, IDictionary destinationLengths) { var length = LineCode.Count; foreach (var arg in Args) { if (arg.Type == ArgType.Pointer) { var destination = model.ReadPointer(start + length); if (destinationLengths != null && !destinationLengths.ContainsKey(destination)) { var argLength = ScriptParser.GetArgLength(model, arg, start + length, destinationLengths); if (argLength > 0) destinationLengths[destination] = argLength; } } length += arg.Length(model, start + length); } return length; } public int CompiledByteLength(IDataModel model, string line) { var length = LineCode.Count; var tokens = line.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); for (var i = 0; i < Args.Count; i++) { if (Args[i] is ScriptArg sarg) length += sarg.Length(default, -1); if (Args[i] is ArrayArg aarg) length += aarg.ConvertMany(model, tokens.Skip(i)).Count() * aarg.TokenLength + 1; } return length; } public ScriptLine(string engineLine) { var docSplit = engineLine.Split(new[] { '#' }, 2); if (docSplit.Length > 1) documentation.Add('#' + docSplit[1]); engineLine = docSplit[0].Trim(); matchingGames = ExtractMatchingGames(ref engineLine); Usage = engineLine.Split(new[] { ' ' }, 2).Last(); var tokens = engineLine.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); var lineCode = new List(); var args = new List(); foreach (var token in tokens) { if (token.Length == 2 && token.All(ViewPort.AllHexCharacters.Contains)) { lineCode.Add(byte.Parse(token, NumberStyles.HexNumber)); } else if (token.StartsWith("[") && token.EndsWith("]")) { var content = token.Substring(1, token.Length - 2); args.Add(new ArrayArg(content)); } else if (ScriptArg.IsValidToken(token)) { args.Add(new ScriptArg(token)); } else { LineCommand = token; } } LineCode = lineCode; Args = args; } public static IReadOnlyList ExtractMatchingGames(ref string line) { if (!line.StartsWith("[")) return null; var gamesEnd = line.IndexOf("]"); if (gamesEnd == -1) return null; var games = line.Substring(1, gamesEnd - 1); line = line.Substring(gamesEnd + 1).TrimStart(); return games.Split("_").Select(ConvertAscii).ToList(); } public static IReadOnlyList GetMatchingGames(IScriptLine line) { var names = new[] { "AXVE", "AXPE", "BPRE", "BPGE", "BPEE" }; return names.Where(name => line.MatchesGame(ConvertAscii(name))).ToList(); } public static int ConvertAscii(string letters) { return letters.Reverse().Aggregate(0, (current, letter) => (current << 8) | (byte)letter); } public bool MatchesGame(int game) => matchingGames?.Contains(game) ?? true; public void AddDocumentation(string doc) => documentation.Add(doc); public bool PartialMatchLine(string line) => LineCommand.MatchesPartial(line.Split(' ')[0]); public bool Matches(int gameCodeHash, IReadOnlyList data, int index) { if (index + LineCode.Count >= data.Count) return false; if (MatchesGame(gameCodeHash)) { var result = true; for (int i = 0; result && i < LineCode.Count; i++) result = data[index + i] == LineCode[i]; // avoid making lambda for performance return result; } return false; } public bool CanCompile(string line) { if (!(line + " ").StartsWith(LineCommand + " ", StringComparison.CurrentCultureIgnoreCase)) return false; if (LineCode.Count == 1) return true; var tokens = Tokenize(line).ToList(); if (tokens.Count < LineCode.Count) return false; tokens.RemoveAt(0); for (int i = 1; i < LineCode.Count; i++) { if (!byte.TryParse(tokens[0], NumberStyles.HexNumber, CultureInfo.CurrentCulture, out var value)) return false; if (value != LineCode[i]) return false; tokens.RemoveAt(0); } return true; } public string ErrorCheck(string scriptLine, out string[] tokens) { tokens = Tokenize(scriptLine); if (!tokens[0].Equals(LineCommand, StringComparison.CurrentCultureIgnoreCase)) throw new ArgumentException($"Command {LineCommand} was expected, but received {tokens[0]} instead."); var commandText = LineCommand; for (int i = 1; i < LineCode.Count; i++) commandText += " " + LineCode[i].ToString("X2"); var fillerCount = Args.Count(arg => arg.Name == "filler"); for (int i = 0; i < fillerCount; i++) { if (tokens.Length < Args.Count + LineCode.Count) tokens = tokens.Append("0").ToArray(); } if (Args.Count > 0 && Args.Last() is ArrayArg) { if (Args.Count > tokens.Length) { return $"Command {commandText} expects {Args.Count} arguments, but received {tokens.Length - LineCode.Count} instead."; } } else if (Args.Count != tokens.Length - LineCode.Count) { return $"Command {commandText} expects {Args.Count} arguments, but received {tokens.Length - LineCode.Count} instead."; } return null; } public string Compile(IDataModel model, int start, string scriptLine, LabelLibrary labels, out byte[] result) { result = null; var error = ErrorCheck(scriptLine, out var tokens); if (error != null) return error; var results = new List(LineCode); start += LineCode.Count; for (int i = 0; i < Args.Count; i++) { if (Args[i] is ScriptArg scriptArg) { var token = tokens[i + LineCode.Count]; var message = scriptArg.Build(model, start, token, results, labels); if (message != null) return message; start += scriptArg.Length(model, start); } else if (Args[i] is ArrayArg arrayArg) { var values = arrayArg.ConvertMany(model, tokens.Skip(i + 1)).ToList(); results.Add((byte)values.Count); start += 1; foreach (var value in values) { if (Args[i].Type == ArgType.Byte) { results.Add((byte)value); start += 1; } else if (Args[i].Type == ArgType.Short) { results.Add((byte)value); results.Add((byte)(value >> 8)); start += 2; } else if (Args[i].Type == ArgType.Word) { results.Add((byte)value); results.Add((byte)(value >> 0x8)); results.Add((byte)(value >> 0x10)); results.Add((byte)(value >> 0x18)); start += 4; } else { throw new NotImplementedException(); } } } } result = results.ToArray(); return null; } public string Decompile(IDataModel data, int start, DecompileLabelLibrary labels) { for (int i = 0; i < LineCode.Count; i++) { if (LineCode[i] != data[start + i]) throw new ArgumentException($"Data at {start:X6} does not match the {LineCommand} command."); } var allFillerIsZero = IsAllFillerZero(data, start); start += LineCode.Count; var builder = new StringBuilder(LineCommand); for (int i = 1; i < LineCode.Count; i++) { builder.Append(" " + LineCode[i].ToHexString()); } var streamContent = new List(); foreach (var arg in Args) { builder.Append(" "); if (arg is ScriptArg scriptArg) { if (scriptArg.Build(allFillerIsZero, data, start, builder, streamContent, labels)) continue; } else if (arg is ArrayArg arrayArg) { builder.Append(arrayArg.ConvertMany(data, start)); } else { throw new NotImplementedException(); } start += arg.Length(data, start); } foreach (var content in streamContent) { builder.AppendLine(); builder.AppendLine("{"); builder.AppendLine(content); builder.Append("}"); } return builder.ToString(); } private bool IsAllFillerZero(IDataModel data, int start) { start += LineCode.Count; foreach (var arg in Args) { if (arg.Name == "filler") { var value = data.ReadMultiByteValue(start, arg.Length(data, start)); if (value != 0) return false; } start += arg.Length(data, start); } return true; } public static string ReadString(IDataModel data, int start) { var length = PCSString.ReadString(data, start, true); return data.TextConverter.Convert(data, start, length); } public static string[] Tokenize(string scriptLine) { var result = new List(); var quoteCut = scriptLine.Split('"'); for (int i = 0; i < quoteCut.Length; i++) { if (i % 2 == 0 && quoteCut[i].Length == 0) continue; if (i % 2 == 1) result.Add($"\"{quoteCut[i]}\""); else result.AddRange(quoteCut[i].Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); } return result.ToArray(); } public override string ToString() { return string.Join(" ", LineCode.Select(code => code.ToHexString()).Concat(Args.Select(arg => arg.Name)).ToArray()); } } public class XSEScriptLine : ScriptLine { public XSEScriptLine(string engineLine) : base(engineLine) { } public override bool IsEndingCommand => LineCode.Count == 1 && LineCode[0].IsAny(0x02, 0x03, 0x05, 0x08, 0x0A, 0x0C, 0x0D); } public class BSEScriptLine : ScriptLine { public BSEScriptLine(string engineLine) : base(engineLine) { } public override bool IsEndingCommand => LineCode.Count == 1 && LineCode[0].IsAny(0x28, 0x3c, 0x3d, 0x3e, 0x3f); } public class ASEScriptLine : ScriptLine { public ASEScriptLine(string engineLine) : base(engineLine) { } public override bool IsEndingCommand => LineCode.Count == 1 && LineCode[0].IsAny(0x08, 0x0F, 0x11, 0x13); } public class TSEScriptLine : ScriptLine { public TSEScriptLine(string engineLine) : base(engineLine) { } public override bool IsEndingCommand => LineCode.Count == 1 && LineCode[0].IsAny(0x45, 0x47, 0x59, 0x5A); } public interface IScriptArg { ArgType Type { get; } ExpectedPointerType PointerType { get; } string Name { get; } string EnumTableName { get; } int Length(IDataModel model, int start); } public class ScriptArg : IScriptArg { private int length; public ArgType Type { get; } public ExpectedPointerType PointerType { get; } public string Name { get; } public string EnumTableName { get; } public int EnumOffset { get; } public int Length(IDataModel model, int start) => length; public ScriptArg(string token) { (Type, PointerType, Name, EnumTableName, length) = Construct(token); if (EnumTableName == null) return; if (EnumTableName.Contains("+")) { var parts = EnumTableName.Split(new[] { '+' }, 2); EnumTableName = parts[0]; if (parts[1].TryParseInt(out var result)) EnumOffset = result; } else if (EnumTableName.Contains("-")) { var parts = EnumTableName.Split(new[] { '-' }, 2); EnumTableName = parts[0]; if (parts[1].TryParseInt(out var result)) EnumOffset = -result; } } public static (ArgType type, ExpectedPointerType pointerType, string name, string enumTableName, int length) Construct(string token) { if (token.Contains("<>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Unknown, name, default, length); } else if (token.Contains("<\"\">")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<\"\">" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Text, name, default, length); } else if (token.Contains("<`mart`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`mart`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Mart, name, default, length); } else if (token.Contains("<`decor`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`decor`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Decor, name, default, length); } else if (token.Contains("<`move`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`move`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Movement, name, default, length); } else if (token.Contains("<`oam`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`oam`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.SpriteTemplate, name, default, length); } else if (token.Contains("<`xse`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`xse`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Script, name, default, length); } else if (token.Contains("<`bse`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`bse`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Script, name, default, length); } else if (token.Contains("<`ase`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`ase`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Script, name, default, length); } else if (token.Contains("<`tse`>")) { var (type, length) = (ArgType.Pointer, 4); var name = token.Split(new[] { "<`tse`>" }, StringSplitOptions.None).First(); return (type, ExpectedPointerType.Script, name, default, length); } else if (token.Contains("::")) { var (type, length) = (ArgType.Word, 4); var name = token.Split(new[] { "::" }, StringSplitOptions.None).First(); var enumTableName = token.Split("::").Last(); return (type, default, name, enumTableName, length); } else if (token.Contains(':')) { var (type, length) = (ArgType.Short, 2); var name = token.Split(':').First(); var enumTableName = token.Split(':').Last(); return (type, default, name, enumTableName, length); } else if (token.Contains('.')) { var (type, length) = (ArgType.Byte, 1); var parts = token.Split(new[] { '.' }, 2); var name = parts[0]; var enumTableName = parts[1]; return (type, default, name, enumTableName, length); } else { // didn't find a token :( // I guess it's a byte? var (type, length) = (ArgType.Byte, 1); var name = token; return (type, default, name, default, length); } } public static bool IsValidToken(string token) { return "<> <`xse`> <`bse`> <`ase`> <`tse`> <\"\"> <`mart`> <`decor`> <`move`> <`oam`> : .".Split(' ').Any(token.Contains); } public string Convert(IDataModel model, int value, int bytes) { var preferHex = EnumTableName?.EndsWith("|h") ?? false; var preferSign = EnumTableName?.EndsWith("|z") ?? false; var enumName = EnumTableName?.Split('|')[0]; var table = string.IsNullOrEmpty(enumName) ? null : model.GetOptions(enumName); if (table == null || value - EnumOffset < 0 || table.Count <= value - EnumOffset || string.IsNullOrEmpty(table[value])) { if (preferHex || value == int.MinValue || Math.Abs(value) >= 0x4000) { return "0x" + ((uint)value).ToString($"X{length * 2}"); } else { if (bytes == 1 && preferSign) value = (sbyte)value; if (bytes == 2 && preferSign) value = (short)value; return value.ToString(); } } return table[value - EnumOffset]; } public int Convert(IDataModel model, string value) { int result; if (!string.IsNullOrEmpty(EnumTableName)) { if (ArrayRunEnumSegment.TryParse(EnumTableName, model, value, out result)) return result + EnumOffset; } if (value.StartsWith("0x") && value.Substring(2).TryParseHex(out result)) return result; if (value.StartsWith("0X") && value.Substring(2).TryParseHex(out result)) return result; if (value.StartsWith("$") && value.Substring(1).TryParseHex(out result)) return result; if (int.TryParse(value, out result)) return result; return 0; } /// /// Build from compiled bytes to text. /// public bool Build(bool allFillerIsZero, IDataModel data, int start, StringBuilder builder, List streamContent, DecompileLabelLibrary labels) { if (allFillerIsZero && Name == "filler") return true; if (Type == ArgType.Byte) builder.Append(Convert(data, data[start], 1)); if (Type == ArgType.Short) builder.Append(Convert(data, data.ReadMultiByteValue(start, 2), 2)); if (Type == ArgType.Word) builder.Append(Convert(data, data.ReadMultiByteValue(start, 4), 4)); if (Type == ArgType.Pointer) { var address = data.ReadMultiByteValue(start, 4); if (address < 0x8000000) { builder.Append(labels.AddressToLabel(address, Type == ArgType.Pointer && PointerType == ExpectedPointerType.Script)); } else { address -= 0x8000000; builder.Append($"<{labels.AddressToLabel(address, Type == ArgType.Pointer && PointerType == ExpectedPointerType.Script)}>"); if (PointerType != ExpectedPointerType.Unknown) { if (data.GetNextRun(address) is IStreamRun stream && stream.Start == address) { streamContent.Add(stream.SerializeRun()); } } } } return false; } /// /// Build from text to compiled bytes. /// public string Build(IDataModel model, int address, string token, IList results, LabelLibrary labels) { if (Type == ArgType.Byte) { results.Add((byte)Convert(model, token)); } else if (Type == ArgType.Short) { var value = Convert(model, token); results.Add((byte)value); results.Add((byte)(value >> 8)); } else if (Type == ArgType.Word) { var value = Convert(model, token); results.Add((byte)value); results.Add((byte)(value >> 0x8)); results.Add((byte)(value >> 0x10)); results.Add((byte)(value >> 0x18)); } else if (Type == ArgType.Pointer) { int value; if (token.StartsWith("<")) { if (!token.EndsWith(">")) return "Unmatched <>"; token = token.Substring(1, token.Length - 2); } if (token.StartsWith("0x")) { token = token.Substring(2); } if (token == "auto") { if (PointerType == ExpectedPointerType.Script || PointerType == ExpectedPointerType.Unknown) { return " only supported for text/data."; } value = Pointer.NULL + DeferredStreamToken.AutoSentinel; } else if (labels.TryResolveLabel(token, out value)) { // resolved to an address } else if (token.TryParseHex(out value)) { // pointer *is* an address: nothing else to do if (value > -Pointer.NULL) value += Pointer.NULL; // public bool RequireCompleteAddresses { get; set; } = true; if (labels.RequireCompleteAddresses && (token.Length < 6 || token.Length > 7)) { return "Script addresses must be 6 or 7 characters long."; } } else if (PointerType != ExpectedPointerType.Script) { return $"'{token}' is not a valid pointer."; } else { labels.AddUnresolvedLabel(token, address); value = Pointer.NULL; } value -= Pointer.NULL; results.Add((byte)value); results.Add((byte)(value >> 0x8)); results.Add((byte)(value >> 0x10)); results.Add((byte)(value >> 0x18)); } else { throw new NotImplementedException(); } return null; } } public class SilentMatchArg : IScriptArg { public ArgType Type => ArgType.Byte; public ExpectedPointerType PointerType => ExpectedPointerType.Unknown; public string Name => null; public string EnumTableName => null; public int EnumOffset => 0; public int Length(IDataModel model, int start) => 1; public byte ExpectedValue { get; } public SilentMatchArg(byte value) => ExpectedValue = value; } public class ArrayArg : IScriptArg { public ArgType Type { get; } public string Name { get; } public string EnumTableName { get; } public int TokenLength { get; } public ExpectedPointerType PointerType => ExpectedPointerType.Unknown; public int Length(IDataModel model, int start) { return model[start] * TokenLength + 1; } public ArrayArg(string token) { (Type, _, Name, EnumTableName, TokenLength) = ScriptArg.Construct(token); } public string ConvertMany(IDataModel model, int start) { var result = new StringBuilder(); var count = model[start]; start++; for (int i = 0; i < count; i++) { var value = model.ReadMultiByteValue(start, TokenLength); start += TokenLength; var tokenText = "0x" + value.ToString($"X{TokenLength * 2}"); if (!string.IsNullOrEmpty(EnumTableName)) { var table = model.GetOptions(EnumTableName); if ((table?.Count ?? 0) > value) { tokenText = table[value]; } } result.Append(tokenText); if (i < count - 1) result.Append(' '); } return result.ToString(); } public IEnumerable ConvertMany(IDataModel model, IEnumerable info) { foreach (var token in info) { if (string.IsNullOrEmpty(EnumTableName)) { if (token.StartsWith("0x") && token.Substring(2).TryParseHex(out var result)) yield return result; else if (token.StartsWith("0X") && token.Substring(2).TryParseHex(out result)) yield return result; else if (token.StartsWith("$") && token.Substring(1).TryParseHex(out result)) yield return result; else if (int.TryParse(token, out result)) yield return result; else yield return 0; } else if (ArrayRunEnumSegment.TryParse(EnumTableName, model, token, out var enumValue)) { yield return enumValue; } else { yield return 0; } } } } public enum ArgType { Byte, Short, Word, Pointer, } public static class ScriptExtensions { public static MacroScriptLine GetMatchingMacro(this IReadOnlyList self, int gameHash, IReadOnlyListdata, int start) { return (MacroScriptLine)self.FirstOrDefault(option => option is MacroScriptLine && option.Matches(gameHash, data, start)); } /// /// Does not consider macros. Only returns individual lines. /// public static ScriptLine GetMatchingLine(this IReadOnlyList self, int gameHash, IReadOnlyList data, int start) { return (ScriptLine)self.FirstOrDefault(option => option is ScriptLine && option.Matches(gameHash, data, start)); } public static int GetScriptSegmentLength(this IReadOnlyList self, int gameHash, IDataModel model, int address, IDictionary destinationLengths) { int length = 0; int lastCommand = -1, repeateLength = 1; while (true) { var line = self.GetMatchingLine(gameHash, model, address + length); if (line == null) break; length += line.CompiledByteLength(model, address + length, destinationLengths); if (line.IsEndingCommand) break; if (line.LineCode[0] != lastCommand) (lastCommand, repeateLength) = (line.LineCode[0], 1); else repeateLength += 1; if (line.Args.Count > 0) repeateLength = 0; if (repeateLength > ScriptParser.MaxRepeates) break; // same command lots of times in a row is fishy } // concatenate destinations directly after the current script // (only if the destination is only used once) while (true) { if (destinationLengths.TryGetValue(address + length, out int argLength) && argLength > 0) { var anchor = model.GetNextAnchor(address + length); if (anchor.Start == address + length && anchor.PointerSources.Count == 1) { length += argLength; continue; } } if (destinationLengths.TryGetValue(address + length + 1, out argLength)) { // there was a skip... should we ignore it? // If something points to that position, we can't keep going. var anchor = model.GetNextAnchor(address + length); if (anchor.Start == address + length && anchor.PointerSources.Count > 0) break; anchor = model.GetNextAnchor(address + length + 1); if (anchor.Start == address + length + 1 && anchor.PointerSources.Count == 1) { length += argLength + 1; continue; } } break; } return length; } } public class DeferredStreamToken { public const int AutoSentinel = -0xAAAA; private readonly int pointerOffset; private readonly string format; private byte[] content; public int ContentLength => content.Length; public DeferredStreamToken(int pointerOffset, string format, byte[] defaultContent) { this.pointerOffset = pointerOffset; this.format = format; this.content = defaultContent; } // need the model not for insertion, but for text encoding public void UpdateContent(IDataModel model, ExpectedPointerType type, string text) { if (type == ExpectedPointerType.Text) { content = model.TextConverter.Convert(text, out _).ToArray(); } else if (type == ExpectedPointerType.Mart) { // [item:{HardcodeTablesModel.ItemsTableName}]!0000 var data = new List(); foreach (var line in text.SplitLines()) { if (string.IsNullOrWhiteSpace(line)) continue; if (!ArrayRunEnumSegment.TryParse(HardcodeTablesModel.ItemsTableName, model, line, out int value)) continue; data.AddShort(value); } data.AddShort(0); content = data.ToArray(); } else if (type == ExpectedPointerType.Mart) { // [item:{HardcodeTablesModel.DecorationsTableName}]!0000 var data = new List(); foreach (var line in text.SplitLines()) { if (string.IsNullOrWhiteSpace(line)) continue; if (!ArrayRunEnumSegment.TryParse(HardcodeTablesModel.DecorationsTableName, model, line, out int value)) continue; data.AddShort(value); } data.AddShort(0); content = data.ToArray(); } else if (type == ExpectedPointerType.Movement) { // $"[move.movementtypes]!FE", var data = new List(); foreach (var line in text.SplitLines()) { if (string.IsNullOrWhiteSpace(line)) continue; if (!ArrayRunEnumSegment.TryParse("movementtypes", model, line, out int value)) continue; data.Add((byte)value); } data.Add(0xFE); content = data.ToArray(); } else { throw new NotImplementedException(); } } public void WriteData(IDataModel model, ModelDelta token, int scriptStart, int contentOffset) { model.ClearFormat(token, scriptStart + pointerOffset, 4); model.WritePointer(token, scriptStart + pointerOffset, scriptStart + contentOffset); model.ObserveRunWritten(token, new PointerRun(scriptStart + pointerOffset)); token.ChangeData(model, scriptStart + contentOffset, content); var strategy = new FormatRunFactory(default).GetStrategy(format); strategy.TryAddFormatAtDestination(model, token, scriptStart + pointerOffset, scriptStart + contentOffset, default, default, default); } public void WriteData(IList data, int scriptStart) { var address = scriptStart + data.Count - Pointer.NULL; data[pointerOffset + 0] = (byte)(address >> 0); data[pointerOffset + 1] = (byte)(address >> 8); data[pointerOffset + 2] = (byte)(address >> 16); data[pointerOffset + 3] = (byte)(address >> 24); data.AddRange(content); } } }