using HavenSoft.HexManiac.Core.Models; using HavenSoft.HexManiac.Core.Models.Code; using HavenSoft.HexManiac.Core.Models.Runs; using HavenSoft.HexManiac.Core.ViewModels.Map; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Text; using System.Windows.Input; namespace HavenSoft.HexManiac.Core.ViewModels.Tools { public enum CodeMode { Thumb, Script, BattleScript, AnimationScript, TrainerAiScript, Raw } public class CodeTool : ViewModelCore, IToolViewModel { public string Name => "Code Tool"; private CodeMode mode; private readonly Singletons singletons; private readonly ThumbParser thumb; private readonly ScriptParser script, battleScript, animationScript, battleAIScript; private readonly ViewPort viewPort; private readonly IDataModel model; private readonly Selection selection; private readonly ChangeHistory history; private readonly IRaiseMessageTab messageTab; private readonly IDelayWorkTimer recompileTimer; public event EventHandler ModelDataChanged; public event EventHandler AttentionNewContent; public bool IsReadOnly => Mode == CodeMode.Raw; public bool UseSingleContent => !UseMultiContent; public bool UseMultiContent => Mode.IsAny(CodeMode.Script, CodeMode.BattleScript, CodeMode.AnimationScript, CodeMode.TrainerAiScript); public IDataInvestigator Investigator { get; set; } private bool isSelected; public bool IsSelected { get => isSelected; set => Set(ref isSelected, value, old => UpdateContent()); } private bool insertAutoActive = true; public bool InsertAutoActive { get => insertAutoActive; set => Set(ref insertAutoActive, value); } private bool showErrorText; public bool ShowErrorText { get => showErrorText; private set => TryUpdate(ref showErrorText, value); } private string errorText; public string ErrorText { get => errorText; private set => TryUpdate(ref errorText, value); } private int fontSize = 12; public int FontSize { get => fontSize; set => TryUpdate(ref fontSize, value); } public CodeMode Mode { get => mode; set { if (!TryUpdateEnum(ref mode, value)) return; UpdateContent(); NotifyPropertyChanged(nameof(IsReadOnly)); NotifyPropertyChanged(nameof(UseSingleContent)); NotifyPropertyChanged(nameof(UseMultiContent)); } } public TextEditorViewModel Editor { get; } = new(); public string Content { get => Editor.Content; set { if (ignoreContentUpdates) return; if (Editor.Content != value) Editor.Content = value; else CompileChanges(); } } public ObservableCollection Contents { get; } = new ObservableCollection(); public ThumbParser Parser => thumb; public ScriptParser ScriptParser => script; public ScriptParser BattleScriptParser => battleScript; public ScriptParser AnimationScriptParser => animationScript; public ScriptParser BattleAIScriptParser => battleAIScript; public event EventHandler<(int originalLocation, int newLocation)> ModelDataMoved; private StubCommand isEventScript; public ICommand IsEventScript => StubCommand(ref isEventScript, ExecuteIsEventScript); private void ExecuteIsEventScript() { var searchPoint = selection.Scroll.ViewPointToDataIndex(selection.SelectionStart); ScriptParser.FormatScript(history.CurrentChange, model, searchPoint); } // properties that exist solely so the UI can remember things when the tab switches public double SingleBoxVerticalOffset { get; set; } public double MultiBoxVerticalOffset { get; set; } public CodeTool(Singletons singletons, ViewPort viewPort, Selection selection, ChangeHistory history, IRaiseMessageTab messageTab) { this.singletons = singletons; recompileTimer = singletons.WorkDispatcher.CreateDelayTimer(); var gameHash = viewPort.Model.GetShortGameCode(); thumb = new ThumbParser(singletons); script = new ScriptParser(gameHash, singletons.ScriptLines, 0x02); battleScript = new ScriptParser(gameHash, singletons.BattleScriptLines, 0x3D); animationScript = new ScriptParser(gameHash, singletons.AnimationScriptLines, 0x08); battleAIScript = new ScriptParser(gameHash, singletons.BattleAIScriptLines, 0x5A); this.viewPort = viewPort; this.model = viewPort.Model; this.selection = selection; this.history = history; this.messageTab = messageTab; selection.PropertyChanged += (sender, e) => { if (e.PropertyName == nameof(selection.SelectionStart)) { if (selection.SelectionStart == selection.SelectionEnd) UpdateContent(); } else if (e.PropertyName == nameof(selection.SelectionEnd)) { UpdateContent(); } }; SetupThumbKeywords(singletons); Editor.Bind(nameof(Editor.Content), (sender, e) => { if (ignoreContentUpdates) return; NotifyPropertyChanged(nameof(Content)); CompileChanges(); }); } private void SetupThumbKeywords(Singletons singletons) { Editor.LineCommentHeader = "@"; Editor.MultiLineCommentHeader = "/*"; Editor.MultiLineCommentFooter = "*/"; Editor.Keywords.Clear(); var set = new HashSet(); foreach (var template in singletons.ThumbInstructionTemplates) { if (template is Instruction instr) { set.Add(instr.Operator); } } set.AddRange(new[] { ".word", ".byte", ".hword", ".align" }); set.AddRange("beq bne bhs blo bcs bcc bmi bpl bvs bvc bhi bls bge blt bgt ble bal bnv".Split(' ')); Editor.Keywords.AddRange(set); Editor.Constants.Clear(); for (int i = 0; i <= 15; i++) Editor.Constants.Add($"r{i}"); Editor.Constants.AddRange(new[] { "lr", "sp", "pc" }); } public void DataForCurrentRunChanged() => UpdateContent(); public void UpdateContent() { if (ignoreContentUpdates || !isSelected) return; var start = Math.Min(model.Count - 1, selection.Scroll.ViewPointToDataIndex(selection.SelectionStart)); var end = Math.Min(model.Count - 1, selection.Scroll.ViewPointToDataIndex(selection.SelectionEnd)); if (start > end) (start, end) = (end, start); int length = end - start + 1; using (Scope(ref ignoreContentUpdates, true, old => ignoreContentUpdates = old)) { if (length > 0x1000) { Editor.Content = "Too many bytes selected."; NotifyPropertyChanged(nameof(Content)); } else if (mode == CodeMode.Raw) { Editor.Content = RawParse(model, start, end - start + 1); NotifyPropertyChanged(nameof(Content)); } else if (length < 2 && mode == CodeMode.Thumb) { Editor.Content = string.Empty; NotifyPropertyChanged(nameof(Content)); UpdateContents(-1, null); CanRepointThumb = CalculateCanRepointThumb(); } else if (mode == CodeMode.Script) { UpdateContents(start, script); } else if (mode == CodeMode.BattleScript) { UpdateContents(start, battleScript); } else if (mode == CodeMode.AnimationScript) { UpdateContents(start, animationScript); } else if (mode == CodeMode.TrainerAiScript) { UpdateContents(start, battleAIScript); } else if (mode == CodeMode.Thumb) { Editor.Content = thumb.Parse(model, start, end - start + 1); NotifyPropertyChanged(nameof(Content)); CanRepointThumb = CalculateCanRepointThumb(); } else { throw new NotImplementedException(); } } } public void ClearConstantCache() { script.ClearConstantCache(); battleScript.ClearConstantCache(); animationScript.ClearConstantCache(); battleAIScript.ClearConstantCache(); } #region RepointThumb private bool CalculateCanRepointThumb() { int left = selection.Scroll.ViewPointToDataIndex(selection.SelectionStart); int right = selection.Scroll.ViewPointToDataIndex(selection.SelectionEnd); if (left > right) (left, right) = (right, left); var length = right - left + 1; return Parser.CanRepoint(model, left, length) != -1; } private bool canRepointThumb; public bool CanRepointThumb { get => canRepointThumb; private set => Set(ref canRepointThumb, value); } public void RepointThumb() { int left = selection.Scroll.ViewPointToDataIndex(selection.SelectionStart); int right = selection.Scroll.ViewPointToDataIndex(selection.SelectionEnd); if (left > right) (left, right) = (right, left); int register = Parser.CanRepoint(model, left, right - left + 1); if (register != -1) { var newAddress = Parser.Repoint(history.CurrentChange, model, left, register); messageTab.RaiseMessage($"Thumb code repointed to {newAddress:X6}"); selection.Goto.Execute(newAddress); selection.SelectionEnd = selection.Scroll.DataIndexToViewPoint(newAddress + 0x13); } } #endregion /// /// Update all the content objects. /// If one of the content objects is the one being changed, don't update that one. /// /// /// private void UpdateContents(int start, ScriptParser parser, int currentScriptStart = -1, int currentScriptLength = -1) { if (currentScriptStart == -1) { ShowErrorText = false; ErrorText = string.Empty; } var scripts = parser?.CollectScripts(model, start) ?? new List(); int skippedScripts = 0; int existingSectionCount = 0; for (int i = 0; i < scripts.Count; i++) { var scriptStart = scripts[i]; if (scriptStart == currentScriptStart && Contents.Count > i && Contents[i].Address == scriptStart) { model.CurrentCacheScope.GetScriptInfo(parser, scriptStart, null, ref existingSectionCount); // mostly to update existingSectionCount continue; } if (currentScriptStart < scriptStart && scriptStart < currentScriptStart + currentScriptLength) { // this script is included inside the current under-edit script // it doesn't need its own content skippedScripts += 1; continue; } var label = scriptStart.ToString("X6"); CodeBody body; if (Contents.Count > i && Contents[i].Parser == parser) { body = Contents[i]; } else { body = new CodeBody(model, parser, Investigator) { Address = scriptStart, Label = label }; parser.AddKeywords(model, body); } var info = model.CurrentCacheScope.GetScriptInfo(parser, scriptStart, body, ref existingSectionCount); bool needsAnimation = false; if (Contents.Count > i) { body.ContentChanged -= ScriptChanged; body.HelpSourceChanged -= UpdateScriptHelpFromLine; body.Content = string.Empty; body.Content = info.Content; body.Address = scriptStart; body.CompiledLength = info.Length; body.Label = label; body.HelpSourceChanged += UpdateScriptHelpFromLine; body.ContentChanged += ScriptChanged; Contents[i] = body; } else { body.CompiledLength = info.Length; body.Content = info.Content; body.ContentChanged += ScriptChanged; body.HelpSourceChanged += UpdateScriptHelpFromLine; body.RequestShowSearchResult += ShowSearchResults; Contents.Add(body); needsAnimation = currentScriptLength != -1; } if (needsAnimation) AttentionNewContent.Raise(this); } while (Contents.Count > scripts.Count - skippedScripts) { Contents[Contents.Count - 1].ContentChanged -= ScriptChanged; Contents.RemoveAt(Contents.Count - 1); } } private void ScriptChanged(object viewModel, ExtendedPropertyChangedEventArgs e) { var parser = mode switch { CodeMode.Script => script, CodeMode.BattleScript => battleScript, CodeMode.AnimationScript => animationScript, CodeMode.TrainerAiScript => battleAIScript, _ => null, }; var body = (CodeBody)viewModel; body.TryCompleteCommandToken(); if (InsertAutoActive && body.TryInsertAuto()) { // update the caret later, or weird stuff happens singletons.WorkDispatcher.DispatchWork(() => singletons.WorkDispatcher.BlockOnUIWork(() => body.Editor.CaretIndex += 6) ); } if (InsertAutoActive) body.TryInsertAuto(); var delta = body.Content.Length - e.OldValue.Length; var deltaSize = Math.Abs(delta); if (body.CaretPosition >= deltaSize && body.CaretPosition < body.Content.Length - deltaSize) { var start = body.Content[0..(body.CaretPosition + delta)]; if (start.EndsWith("")) InsertAutoActive = true; start = body.Content[0..(body.CaretPosition + delta)]; if (start.EndsWith(" { var initialStart = selection.Scroll.ViewPointToDataIndex(selection.SelectionStart); var initialEnd = selection.Scroll.ViewPointToDataIndex(selection.SelectionEnd); if (initialStart > initialEnd) (initialStart, initialEnd) = (initialEnd, initialStart); if (mode == CodeMode.Script) { CompileScriptChanges(body, run, ref codeContent, e.OldValue, parser, body == Contents[0]); } else if (mode == CodeMode.AnimationScript) { CompileScriptChanges(body, run, ref codeContent, e.OldValue, parser, body == Contents[0]); } else if (mode == CodeMode.BattleScript) { CompileScriptChanges(body, run, ref codeContent, e.OldValue, parser, body == Contents[0]); } else if (mode == CodeMode.TrainerAiScript) { CompileScriptChanges(body, run, ref codeContent, e.OldValue, parser, body == Contents[0]); } body.ContentChanged -= ScriptChanged; body.HelpSourceChanged -= UpdateScriptHelpFromLine; body.Content = codeContent; body.HelpSourceChanged += UpdateScriptHelpFromLine; body.ContentChanged += ScriptChanged; // reload var start = Math.Min(model.Count - 1, selection.Scroll.ViewPointToDataIndex(selection.SelectionStart)); var end = Math.Min(model.Count - 1, selection.Scroll.ViewPointToDataIndex(selection.SelectionEnd)); if (start > end) (start, end) = (end, start); if (initialStart == body.Address) { length = end - start + 1; body.Address = start; // in case of the code getting repointed } UpdateContents(start, parser, body.Address, length); }; // don't need to run this if they're still typing /* action(); /*/ body.CaretPosition += ScriptParser.InsertMissingClosers(ref codeContent, body.CaretPosition); body.Content = codeContent; recompileTimer.DelayCall(TimeSpan.FromSeconds(.5), action); //*/ } private void UpdateScriptHelpFromLine(object sender, HelpContext context) { var codeBody = (CodeBody)sender; string help; if (mode == CodeMode.Script) help = ScriptParser.GetHelp(model, codeBody, context); else if (mode == CodeMode.BattleScript) help = BattleScriptParser.GetHelp(model, codeBody, context); else if (mode == CodeMode.AnimationScript) help = AnimationScriptParser.GetHelp(model, codeBody, context); else if (mode == CodeMode.TrainerAiScript) help = BattleAIScriptParser.GetHelp(model, codeBody, context); else throw new NotImplementedException(); codeBody.HelpContent = help; } private void ShowSearchResults(object sender, ISet<(int, int)> results) { viewPort.OpenSearchResultsTab("Script Search Results", results.ToList()); } private void CompileChanges() { using (ModelCacheScope.CreateScope(model)) { if (mode == CodeMode.Thumb) CompileThumbChanges(); } } private void CompileThumbChanges() { var start = Math.Min(model.Count - 1, selection.Scroll.ViewPointToDataIndex(selection.SelectionStart)); var end = Math.Min(model.Count - 1, selection.Scroll.ViewPointToDataIndex(selection.SelectionEnd)); if (start > end) (start, end) = (end, start); int length = end - start + 1; int originalLength = length; var code = thumb.Compile(model, start, out var newRuns, Content.Split(new[] { Environment.NewLine, "\n" }, StringSplitOptions.None)); // if more length is needed and the next available bytes are free, allow it. while (code.Count > length && model.Count > start + length + 1 && model[start + length] == 0xFF && model[start + length + 1] == 0xFF) length += 2; if (code.Count > length) { ErrorText = $"Thumb compiled to {code.Count} bytes, but only {length} bytes are available."; ShowErrorText = true; return; } ShowErrorText = false; model.ClearFormat(history.CurrentChange, start + 1, length - 1); for (int i = 0; i < code.Count; i++) history.CurrentChange.ChangeData(model, start + i, code[i]); for (int i = code.Count; i < length; i++) history.CurrentChange.ChangeData(model, start + i, 0xFF); foreach (var run in newRuns) model.ObserveRunWritten(history.CurrentChange, run); ModelDataChanged?.Invoke(this, ErrorInfo.NoError); if (length > originalLength) { using (CreateRecursionGuard()) { selection.SelectionStart = selection.Scroll.DataIndexToViewPoint(start); selection.SelectionEnd = selection.Scroll.DataIndexToViewPoint(start + length - 1); } } } private SERun Construct(int start, SortedSpan sources) where SERun : IScriptStartRun { if (typeof(SERun) == typeof(XSERun)) return (SERun)(IScriptStartRun)new XSERun(start, sources); if (typeof(SERun) == typeof(ASERun)) return (SERun)(IScriptStartRun)new ASERun(start, sources); if (typeof(SERun) == typeof(BSERun)) return (SERun)(IScriptStartRun)new BSERun(start, sources); if (typeof(SERun) == typeof(TSERun)) return (SERun)(IScriptStartRun)new TSERun(start, sources); throw new NotImplementedException(); } bool ignoreContentUpdates; private IDisposable CreateRecursionGuard() { if (ignoreContentUpdates) return new StubDisposable(); ignoreContentUpdates = true; return new StubDisposable { Dispose = () => ignoreContentUpdates = false }; } private void CompileScriptChanges(CodeBody body, IFormattedRun run, ref string codeContent, string previousText, ScriptParser parser, bool updateSelection) where SERun : IScriptStartRun { ShowErrorText = false; ErrorText = string.Empty; var sources = run?.PointerSources ?? null; int start = body.Address; int length = body.CompiledLength; using (CreateRecursionGuard()) { var oldScripts = parser.CollectScripts(model, start); var originalCodeContent = codeContent; int caret = body.CaretPosition; body.ClearErrors(); body.EvaluateTextLength(); parser.CompileError += body.WatchForCompileErrors; var code = parser.Compile(history.CurrentChange, model, start, ref codeContent, ref caret, body, out var movedData, out int ignoreCharacterCount); parser.CompileError -= body.WatchForCompileErrors; if (originalCodeContent != codeContent) body.Editor.CaretIndex = caret + codeContent.Length - previousText.Length - ignoreCharacterCount; if (code == null) { return; } if (code.Length > length) { model.ExpandData(history.CurrentChange, start + code.Length - 1); selection.Scroll.DataLength = model.RawData.Length; if (run == null) { var availableLength = length; for (int i = start + length; i < start + code.Length; i++) { // if it's freespace, then it's available if (model[i] == 0xFF) { availableLength++; continue; } if (model.GetNextRun(i) is IScriptStartRun scriptRun && scriptRun.Start == i) { // the next byte is a script... maybe it's ok to overwrite it // we can overwrite it if it passes 2 checks // (1) the only pointers to that script are contained within the script we're currently compiling // (2) the script is contained completely within the compiled code (meaning it's actually part of the script as written) if (scriptRun.PointerSources.All(source => start < source && source < start + length)) { var scriptLength = parser.FindLength(model, scriptRun.Start, model.CurrentCacheScope.ScriptDestinations(start)); if (i + scriptLength <= start + code.Length) { i += scriptLength - 1; availableLength += scriptLength; continue; } } } ErrorText = $"Script is {code.Length} bytes long, but only {availableLength} bytes are available."; ShowErrorText = true; return; } } else { if (run is NoInfoRun) run = Construct(run.Start, run.PointerSources); run = model.RelocateForExpansion(history.CurrentChange, run, body.CompiledLength, code.Length); if (start != run.Start) { ModelDataMoved?.Invoke(this, (start, run.Start)); start = run.Start; int changedCaret = body.CaretPosition; code = parser.Compile(history.CurrentChange, model, start, ref codeContent, ref changedCaret, body, out movedData, out var _); // recompile for the new location. Could update pointers. // assume that changedCaret == body.CaretPosition? But it's probably not important sources = run.PointerSources; } } } // pre-clear the format before we change the data // waiting beyond this point won't clear anchors correctly, since pointer values will be changed var changeStart = code.Length; for (int i = 0; i < code.Length; i++) { if (model[start + i] == code[i]) continue; changeStart = Math.Max(1, i); // changeStart should never be zero: we don't want to clear the script anchor break; } if (changeStart < code.Length) { // use a nodatachange token here: we want to keep bytes, not anchor names var change = history.InsertCustomChange(new NoDataChangeDeltaModel()); model.ClearFormat(change, start + changeStart, code.Length - changeStart); } var anyChanges = history.CurrentChange.ChangeData(model, start, code); if (anyChanges || body.CompiledLength != code.Length) { body.CompiledLength = code.Length; model.ClearFormatAndData(history.CurrentChange, start + code.Length, length - code.Length); } var formatted = parser.FormatScript(history.CurrentChange, model, start, code.Length); if (sources != null) { foreach (var source in sources) { // skip the source if it's within one of the added scripts: it may have moved, and we've already added it. if (formatted.Any(kvp => source.InRange(kvp.Key, kvp.Key + kvp.Value))) continue; var existingRun = model.GetNextRun(source); if (existingRun.Start > source || !(existingRun is ITableRun)) { model.ObserveRunWritten(history.CurrentChange, new PointerRun(source)); } } } // this change may have orphaned some existing scripts. Don't lose them! var newScripts = parser.CollectScripts(model, start); var orphans = oldScripts.Except(newScripts).ToList(); foreach (var orphan in orphans) { var orphanRun = model.GetNextRun(orphan); if (orphanRun.Start == orphan && orphanRun.PointerSources.IsNullOrEmpty() && string.IsNullOrEmpty(model.GetAnchorFromAddress(-1, orphan))) { parser.FormatScript(history.CurrentChange, model, orphan); if (typeof(SERun) == typeof(XSERun)) { model.ObserveAnchorWritten(history.CurrentChange, $"orphans.xse{orphan:X6}", new XSERun(orphan)); } else if (typeof(SERun) == typeof(BSERun)) { model.ObserveAnchorWritten(history.CurrentChange, $"orphans.bse{orphan:X6}", new BSERun(orphan)); } else if (typeof(SERun) == typeof(ASERun)) { model.ObserveAnchorWritten(history.CurrentChange, $"orphans.ase{orphan:X6}", new ASERun(orphan)); } else if (typeof(SERun) == typeof(TSERun)) { model.ObserveAnchorWritten(history.CurrentChange, $"orphans.tse{orphan:X6}", new TSERun(orphan)); } else { throw new NotImplementedException(); } } } if (updateSelection) { selection.SelectionStart = selection.Scroll.DataIndexToViewPoint(start); selection.SelectionEnd = selection.Scroll.DataIndexToViewPoint(start + code.Length - 1); } foreach (var movedResource in movedData) ModelDataMoved?.Invoke(this, movedResource); } ModelDataChanged?.Invoke(this, ErrorInfo.NoError); } private string RawParse(IDataModel model, int start, int length) { var builder = new StringBuilder(); while (length > 0) { builder.Append(model[start].ToHexString()); builder.Append(" "); length--; start++; if (start % 16 == 0) builder.AppendLine(); } return builder.ToString(); } } public class CodeTextFormatter : ITextPreProcessor { public TextFormatting[] Format(string content) { var result = new TextFormatting[content.Length]; bool inText = false, inComment = false; for (int i = 0; i < content.Length; i++) { if (inComment && content[i] == '\n') inComment = false; if (content[i] == '#') inComment = true; if (inComment) continue; if (content[i] == '{') inText = true; else if (content[i] == '}') inText = false; else if (inText) result[i] = TextFormatting.Text; } return result; } public IEnumerable FindErrors(string content) { yield break; } } public record HelpContext(string Line, int Index, int ContentBoundaryCount = 0, int ContentBoundaryIndex = -1, bool IsSelection = false); }