using HavenSoft.HexManiac.Core.Models; using HavenSoft.HexManiac.Core.Models.Runs; using HavenSoft.HexManiac.Core.Models.Runs.Factory; using HavenSoft.HexManiac.Core.Models.Runs.Sprites; using HavenSoft.HexManiac.Core.ViewModels.DataFormats; using HavenSoft.HexManiac.Core.ViewModels.Tools; using HavenSoft.HexManiac.Core.ViewModels.Visitors; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Windows.Input; using static HavenSoft.HexManiac.Core.ICommandExtensions; using static HavenSoft.HexManiac.Core.Models.Runs.ArrayRun; using static HavenSoft.HexManiac.Core.Models.Runs.BaseRun; using static HavenSoft.HexManiac.Core.Models.Runs.PCSRun; using static HavenSoft.HexManiac.Core.Models.Runs.PointerRun; namespace HavenSoft.HexManiac.Core.ViewModels { /// /// A range of visible data that should be displayed. /// public class ViewPort : ViewModelCore, IEditableViewPort, IRaiseMessageTab { public const string AllHexCharacters = "0123456789ABCDEFabcdef"; public const char GotoMarker = '@'; public const char DirectiveMarker = '.'; // for things like .thumb, .align, etc. Directives always start with a single dot and contain no further dots until they contain a space. public const char CommandMarker = '!'; // commands are meta, so they also start with the goto marker. public const char CommentStart = '#'; public const int CopyLimit = 20000; private static readonly NotifyCollectionChangedEventArgs ResetArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); private readonly StubCommand undoWrapper = new StubCommand(), redoWrapper = new StubCommand(), clear = new StubCommand(), selectAll = new StubCommand(), copy = new StubCommand(), copyAddress = new StubCommand(), copyBytes = new StubCommand(), deepCopy = new StubCommand(), isText = new StubCommand(); public Singletons Singletons { get; } private HexElement[,] currentView; private bool exitEditEarly, withinComment, skipToNextGameCode; public string Name { get { var name = Path.GetFileNameWithoutExtension(FileName); if (string.IsNullOrEmpty(name)) name = "Untitled"; if (history.HasDataChange) name += "*"; return name; } } private string fileName; public string FileName { get => fileName; private set { if (TryUpdate(ref fileName, value) && !string.IsNullOrEmpty(fileName)) { FullFileName = Path.GetFullPath(fileName); NotifyPropertyChanged(nameof(Name)); } } } private string fullFileName; public string FullFileName { get => fullFileName; private set => TryUpdate(ref fullFileName, value); } #region Scrolling Properties private readonly ScrollRegion scroll; public event EventHandler PreviewScrollChanged; public int Width { get => scroll.Width; set { using (ModelCacheScope.CreateScope(Model)) selection.ChangeWidth(value); } } public int Height { get => scroll.Height; set { using (ModelCacheScope.CreateScope(Model)) scroll.Height = value; } } public int MinimumScroll => scroll.MinimumScroll; public int ScrollValue { get => scroll.ScrollValue; set { PreviewScrollChanged?.Invoke(this, EventArgs.Empty); using (ModelCacheScope.CreateScope(Model)) scroll.ScrollValue = value; } } public int MaximumScroll => scroll.MaximumScroll; public ObservableCollection Headers => scroll.Headers; public ObservableCollection ColumnHeaders { get; } public int DataOffset => scroll.DataIndex; public ICommand Scroll => scroll.Scroll; public bool UseCustomHeaders { get => scroll.UseCustomHeaders; set { using (ModelCacheScope.CreateScope(Model)) scroll.UseCustomHeaders = value; } } private void ScrollPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(scroll.DataIndex)) { RefreshBackingData(); if (e is ExtendedPropertyChangedEventArgs ex) { var previous = ex.OldValue; if (Math.Abs(scroll.DataIndex - previous) % Width != 0) UpdateColumnHeaders(); } } else if (e.PropertyName != nameof(scroll.DataLength)) { NotifyPropertyChanged(e); } if (e.PropertyName == nameof(Width) || e.PropertyName == nameof(Height)) { RefreshBackingData(); } if (e.PropertyName == nameof(Width)) { UpdateColumnHeaders(); NotifyPropertyChanged(nameof(ScrollValue)); // changing the Scroll's Width can mess with the ScrollValue: go ahead and notify } } #endregion #region Selection Properties private readonly Selection selection; private bool stretchData; public bool StretchData { get => stretchData; set => Set(ref stretchData, value); } public bool AutoAdjustDataWidth { get => selection.AutoAdjustDataWidth; set => selection.AutoAdjustDataWidth = value; } public bool AllowMultipleElementsPerLine { get => selection.AllowMultipleElementsPerLine; set => selection.AllowMultipleElementsPerLine = value; } public Point SelectionStart { get => selection.SelectionStart; set => selection.SelectionStart = value; } public Point SelectionEnd { get => selection.SelectionEnd; set => selection.SelectionEnd = value; } public int PreferredWidth { get => selection.PreferredWidth; set => selection.PreferredWidth = value; } private readonly StubCommand moveSelectionStart = new StubCommand(), moveSelectionEnd = new StubCommand(); public ICommand MoveSelectionStart => moveSelectionStart; public ICommand MoveSelectionEnd => moveSelectionEnd; public ICommand Goto => StubCommand(ref gotoCommand, ExecuteGoto, selection.Goto.CanExecute); public ICommand Back => selection.Back; public ICommand Forward => selection.Forward; public ICommand ResetAlignment => selection.ResetAlignment; public ICommand SelectAll => selectAll; private StubCommand gotoCommand; private void ExecuteGoto(object arg) { if (arg is string str) { var words = Model.GetMatchedWords(str).Where(word => Model.GetNextRun(word).Length < 3).ToList(); if (words.Count == 1) { selection.Goto.Execute(words[0]); return; } else if (words.Count > 1) { OpenSearchResultsTab(str, words.Select(word => (word, word)).ToList()); return; } } selection.Goto.Execute(arg); } private void ClearActiveEditBeforeSelectionChanges(object sender, Point location) { if (location.X >= 0 && location.X < scroll.Width && location.Y >= 0 && location.Y < scroll.Height) { var element = this[location.X, location.Y]; var underEdit = element.Format as UnderEdit; if (underEdit != null) { using (ModelCacheScope.CreateScope(Model)) { if (underEdit.CurrentText == string.Empty) { var index = scroll.ViewPointToDataIndex(location); var operation = new DataClear(Model, history.CurrentChange, index); underEdit.OriginalFormat.Visit(operation, Model[index]); ClearEdits(location); } else { var endEdit = " "; if (underEdit.CurrentText.Count(c => c == StringDelimeter) % 2 == 1) endEdit = StringDelimeter.ToString(); var originalFormat = underEdit.OriginalFormat; if (originalFormat is Anchor anchor) originalFormat = anchor.OriginalFormat; if (originalFormat is SpriteDecorator sprite) originalFormat = sprite.OriginalFormat; if (underEdit.CurrentText.StartsWith(EggMoveRun.GroupStart) && (originalFormat is EggSection || originalFormat is EggItem)) endEdit = EggMoveRun.GroupEnd; currentView[location.X, location.Y] = new HexElement(element.Value, element.Edited, underEdit.Edit(endEdit)); if (!TryCompleteEdit(location)) ClearEdits(location); } } } } } private void SelectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(SelectionEnd)) history.ChangeCompleted(); NotifyPropertyChanged(e.PropertyName); var dataIndex = scroll.ViewPointToDataIndex(SelectionStart); using (ModelCacheScope.CreateScope(Model)) { UpdateToolsFromSelection(dataIndex); UpdateSelectedAddress(); UpdateSelectedBytes(); } } public void UpdateToolsFromSelection(int dataIndex) { var run = Model.GetNextRun(dataIndex); if (run.Start > dataIndex) { AnchorTextVisible = false; return; } // if the user explicitly closed the tools, don't auto-open them. if (tools.SelectedIndex != -1) { // if the 'Raw' tool is selected, don't auto-update tool selection. if (!(tools.SelectedTool == tools.CodeTool && tools.CodeTool.Mode == CodeMode.Raw)) { using (ModelCacheScope.CreateScope(Model)) { // update the tool from pointers too if (run is PointerRun) { var destination = Model.ReadPointer(run.Start); run = Model.GetNextRun(destination); dataIndex = destination; } if (run is ISpriteRun spriteRun) { var tool = tools.SpriteTool; if (tool.SpriteAddress != run.Start) { tool.SpriteAddress = run.Start; } else { tool.UpdateSpriteProperties(); tool.PaletteAddress = SpriteTool.FindMatchingPalette(Model, spriteRun, tool.PaletteAddress); } tools.SelectedIndex = tools.IndexOf(tools.SpriteTool); } else if (run is IPaletteRun) { tools.SpriteTool.PaletteAddress = run.Start; tools.SelectedIndex = tools.IndexOf(tools.SpriteTool); } else if (run is ITableRun array) { var offsets = array.ConvertByteOffsetToArrayOffset(dataIndex); Tools.StringTool.Address = offsets.SegmentStart - offsets.ElementIndex * array.ElementLength; Tools.TableTool.Address = array.Start + array.ElementLength * offsets.ElementIndex; if (!(run is IStreamRun || array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.PCS) || tools.SelectedTool != tools.StringTool) { tools.SelectedIndex = tools.IndexOf(tools.TableTool); } } else if (run is IStreamRun) { Tools.StringTool.Address = run.Start; tools.SelectedIndex = tools.IndexOf(tools.StringTool); } else if (run is IScriptStartRun) { tools.SelectedIndex = tools.IndexOf(tools.CodeTool); if (run is XSERun) tools.CodeTool.Mode = CodeMode.Script; if (run is BSERun) tools.CodeTool.Mode = CodeMode.BattleScript; if (run is ASERun) tools.CodeTool.Mode = CodeMode.AnimationScript; } else { // not a special run, so don't update tools } } } } if (this[SelectionStart].Format is Anchor anchor) { // there is an anchor exactly here: show that format TryUpdate(ref anchorText, AnchorStart + anchor.Name + anchor.Format, nameof(AnchorText)); AnchorTextVisible = true; } else if (run.Start <= dataIndex && run.PointerSources != null) { // there is an anchor attached to this run: show that format var name = Model.GetAnchorFromAddress(-1, run.Start); TryUpdate(ref anchorText, AnchorStart + name + run.FormatString, nameof(AnchorText)); AnchorTextVisible = true; } else { AnchorTextVisible = false; } RequestMenuClose?.Invoke(this, EventArgs.Empty); } private string selectedAddress; public string SelectedAddress { get => selectedAddress; private set => TryUpdate(ref selectedAddress, value); } private void UpdateSelectedAddress() { var dataIndex1 = scroll.ViewPointToDataIndex(SelectionStart); var dataIndex2 = scroll.ViewPointToDataIndex(SelectionEnd); var left = Math.Min(dataIndex1, dataIndex2); var result = "Address: " + left.ToString("X6"); var elementName = BuildElementName(Model, left); if (!string.IsNullOrWhiteSpace(elementName)) result += $" | {elementName}"; if (!SelectionStart.Equals(SelectionEnd)) { int length = Math.Abs(dataIndex1 - dataIndex2) + 1; result += $" | {length} bytes selected"; } SelectedAddress = result; } public static string BuildElementName(IDataModel model, int address) { var run = model.GetNextRun(address); if (run is ITableRun array1 && array1.Start <= address) { var index = array1.ConvertByteOffsetToArrayOffset(address).ElementIndex; var basename = model.GetAnchorFromAddress(-1, array1.Start); if (array1.ElementNames.Count > index) { return $"{basename}/{array1.ElementNames[index]}"; } else { return $"{basename}/{index}"; } } else if (run.PointerSources != null && run.PointerSources.Count > 0 && string.IsNullOrEmpty(model.GetAnchorFromAddress(-1, run.Start))) { var sourceRun = model.GetNextRun(run.PointerSources[0]); if (sourceRun is ITableRun array2) { // we are an anchor that's pointed to from an array var offset = array2.ConvertByteOffsetToArrayOffset(run.PointerSources[0]); var index = offset.ElementIndex; if (index >= 0) { var segment = array2.ElementContent[offset.SegmentIndex]; var basename = model.GetAnchorFromAddress(-1, array2.Start); if (array2.ElementNames.Count > index) { return $"{basename}/{array2.ElementNames[index]}/{segment.Name}"; } else { return $"{basename}/{index}/{segment.Name}"; } } } } return string.Empty; } private string selectedBytes; public string SelectedBytes { get { if (selectedBytes != null) return selectedBytes; var bytes = GetSelectedByteContents(0x10); selectedBytes = "Selected Bytes: " + bytes; return selectedBytes; } private set => TryUpdate(ref selectedBytes, value); } // update the selected bytes lazily. Most of the time we don't really care about the new value. private void UpdateSelectedBytes() => SelectedBytes = null; private string GetSelectedByteContents(int maxByteCount = int.MaxValue) { var dataIndex1 = scroll.ViewPointToDataIndex(SelectionStart); var dataIndex2 = scroll.ViewPointToDataIndex(SelectionEnd); var left = Math.Min(dataIndex1, dataIndex2); var length = Math.Abs(dataIndex1 - dataIndex2) + 1; if (left < 0) { length += left; left = 0; } if (left + length > Model.Count) length = Model.Count - left; var result = new StringBuilder(); for (int i = 0; i < length && i < maxByteCount; i++) { var token = Model[left + i].ToHexString(); result.Append(token); result.Append(" "); } if (maxByteCount < length) result.Append("..."); return result.ToString(); } private void SelectAllExecuted() { Goto.Execute(0); SelectionStart = new Point(0, 0); SelectionEnd = scroll.DataIndexToViewPoint(Model.Count - 1); } #endregion #region Undo / Redo private readonly ChangeHistory history; public ChangeHistory ChangeHistory => history; public ModelDelta CurrentChange => history.CurrentChange; public ICommand Undo => undoWrapper; public ICommand Redo => redoWrapper; private ModelDelta RevertChanges(ModelDelta changes) { var reverse = changes.Revert(Model); RefreshBackingData(); return reverse; } private void HistoryPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(history.IsSaved)) { save.RaiseCanExecuteChanged(); exportBackup.RaiseCanExecuteChanged(); if (history.IsSaved) { Model.ResetChanges(); RefreshBackingData(); } } if (e.PropertyName == nameof(history.HasDataChange)) NotifyPropertyChanged(nameof(Name)); } #endregion #region Saving private readonly StubCommand save = new StubCommand(), saveAs = new StubCommand(), exportBackup = new StubCommand(), close = new StubCommand(); public ICommand Save => save; public ICommand SaveAs => saveAs; public ICommand ExportBackup => exportBackup; public ICommand Close => close; public event EventHandler Closed; private void SaveExecuted(IFileSystem fileSystem) { if (history.IsSaved) return; if (string.IsNullOrEmpty(FileName)) { SaveAsExecuted(fileSystem); return; } var metadata = Model.ExportMetadata(Singletons.MetadataInfo); if (fileSystem.Save(new LoadedFile(FileName, Model.RawData))) { fileSystem.SaveMetadata(FileName, metadata?.Serialize()); history.TagAsSaved(); Model.ResetChanges(); } } private void SaveAsExecuted(IFileSystem fileSystem) { var newName = fileSystem.RequestNewName(FileName, "GameBoy Advanced", "gba"); if (newName == null) return; var metadata = Model.ExportMetadata(Singletons.MetadataInfo); if (fileSystem.Save(new LoadedFile(newName, Model.RawData))) { FileName = newName; // don't bother notifying, because tagging the history will cause a notify; fileSystem.SaveMetadata(FileName, metadata?.Serialize()); history.TagAsSaved(); Model.ResetChanges(); } } private void ExportBackupExecuted(IFileSystem fileSystem) { var changeDescription = fileSystem.RequestText("Export Summary", "What was your most recent change?"); if (changeDescription == null) return; changeDescription = new string(changeDescription.Where(char.IsLetterOrDigit).ToArray()); var exportID = Model.NextExportID; Model.NextExportID += 1; var metadata = Model.ExportMetadata(Singletons.MetadataInfo); var fileName = Path.GetFileNameWithoutExtension(FullFileName); fileName = fileName.Split("_backup")[0]; var extension = Path.GetExtension(FullFileName); var directory = Path.GetDirectoryName(FullFileName); if (!string.IsNullOrEmpty(directory)) directory = directory + Path.DirectorySeparatorChar; var exportName = $"{directory}backups{Path.DirectorySeparatorChar}{fileName}_backup{exportID}__{changeDescription}{extension}"; if (fileSystem.Save(new LoadedFile(exportName, Model.RawData))) { fileSystem.SaveMetadata(exportName, metadata?.Serialize()); } } private void CloseExecuted(IFileSystem fileSystem) { if (!history.IsSaved) { var metadata = Model.ExportMetadata(Singletons.MetadataInfo); var result = fileSystem.TrySavePrompt(new LoadedFile(FileName, Model.RawData)); if (result == null) return; if (result == true) { fileSystem.SaveMetadata(FileName, metadata?.Serialize()); } } Closed?.Invoke(this, EventArgs.Empty); } #endregion #region Progress private double progress; public double Progress { get => progress; set => Set(ref progress, value); } private bool updateInProgress; public bool UpdateInProgress { get => updateInProgress; set => Set(ref updateInProgress, value); } private int initialWorkLoad; private readonly List CurrentProgressScopes = new List(); #endregion public int FreeSpaceStart { get => Model.FreeSpaceStart; set { if (Model.FreeSpaceStart != value) { Model.FreeSpaceStart = value; NotifyPropertyChanged(); } } } private StubCommand gotoFreeSpaceStart; public ICommand GotoFreeSpaceStart => StubCommand(ref gotoFreeSpaceStart, () => Goto.Execute(Model.FreeSpaceStart)); private readonly ToolTray tools; public bool HasTools => true; public IToolTrayViewModel Tools => tools; private bool anchorTextVisible; public bool AnchorTextVisible { get => anchorTextVisible; set => Set(ref anchorTextVisible, value); } private string anchorText; public string AnchorText { get => anchorText; set { if (value == null) value = string.Empty; if (!value.StartsWith(AnchorStart.ToString())) value = AnchorStart + value; if (TryUpdate(ref anchorText, value)) { var index = scroll.ViewPointToDataIndex(SelectionStart); var run = Model.GetNextRun(index); if (run.Start <= index) { var token = new NoDataChangeDeltaModel(); var errorInfo = PokemonModel.ApplyAnchor(Model, token, run.Start, AnchorText); if (errorInfo == ErrorInfo.NoError) { OnError?.Invoke(this, string.Empty); var newRun = Model.GetNextRun(index); if (AnchorText == AnchorStart.ToString()) Model.ClearFormat(token, run.Start, 1); if (newRun is ArrayRun array) { // if the format changed (ignoring length), run a goto to update the display width if (run is ArrayRun array2 && !array.HasSameSegments(array2)) { selection.PropertyChanged -= SelectionPropertyChanged; // to keep from double-updating the AnchorText Goto.Execute(index); selection.PropertyChanged += SelectionPropertyChanged; } UpdateColumnHeaders(); } Tools.RefreshContent(); RefreshBackingData(); } else { OnError?.Invoke(this, errorInfo.ErrorMessage); } if (token.HasAnyChange) history.InsertCustomChange(token); } } } } private int anchorTextSelectionStart; public int AnchorTextSelectionStart { get => anchorTextSelectionStart; set => Set(ref anchorTextSelectionStart, value); } private int anchorTextSelectionLength; public int AnchorTextSelectionLength { get => anchorTextSelectionLength; set => Set(ref anchorTextSelectionLength, value); } public ICommand Copy => copy; public ICommand CopyAddress => copyAddress; public ICommand CopyBytes => copyBytes; public ICommand DeepCopy => deepCopy; public ICommand Clear => clear; public ICommand IsText => isText; public HexElement this[Point p] => this[p.X, p.Y]; public HexElement this[int x, int y] { get { if (x < 0 || x >= Width || x >= currentView.GetLength(0)) return HexElement.Undefined; if (y < 0 || y >= Height || y >= currentView.GetLength(1)) return HexElement.Undefined; if (currentView[x, y] is object) return currentView[x, y]; if (x == 0 && y == 0) { RefreshBackingDataFull(); return currentView[x, y]; } using (ModelCacheScope.CreateScope(Model)) { RefreshBackingData(new Point(x, y)); } return currentView[x, y]; } } public IDataModel Model { get; } public bool FormattedDataIsSelected { get { var (left, right) = (scroll.ViewPointToDataIndex(SelectionStart), scroll.ViewPointToDataIndex(SelectionEnd)); if (left > right) (left, right) = (right, left); var nextRun = Model.GetNextRun(left); return nextRun.Start <= right; } } #pragma warning disable 0067 // it's ok if events are never used public event EventHandler OnError; public event EventHandler OnMessage; public event EventHandler ClearMessage; public event NotifyCollectionChangedEventHandler CollectionChanged; public event EventHandler RequestTabChange; public event EventHandler RequestDelayedWork; public event EventHandler RequestMenuClose; #pragma warning restore 0067 #region Constructors public ViewPort() : this(new LoadedFile(string.Empty, new byte[0])) { } public ViewPort(string fileName, IDataModel model, IWorkDispatcher dispatcher, Singletons singletons = null, ChangeHistory changeHistory = null) { Singletons = singletons ?? new Singletons(); history = changeHistory ?? new ChangeHistory(RevertChanges); history.PropertyChanged += HistoryPropertyChanged; this.dispatcher = dispatcher ?? InstantDispatch.Instance; Model = model; FileName = fileName; ColumnHeaders = new ObservableCollection(); scroll = new ScrollRegion(model.TryGetUsefulHeader) { DataLength = Model.Count }; scroll.PropertyChanged += ScrollPropertyChanged; selection = new Selection(scroll, Model, history, GetSelectionSpan); selection.PropertyChanged += SelectionPropertyChanged; selection.PreviewSelectionStartChanged += ClearActiveEditBeforeSelectionChanges; selection.OnError += (sender, e) => OnError?.Invoke(this, e); tools = new ToolTray(Singletons, Model, selection, history, this); Tools.OnError += (sender, e) => OnError?.Invoke(this, e); Tools.OnMessage += (sender, e) => RaiseMessage(e); tools.RequestMenuClose += (sender, e) => RequestMenuClose?.Invoke(this, e); Tools.StringTool.ModelDataChanged += ModelChangedByTool; Tools.StringTool.ModelDataMoved += ModelDataMovedByTool; Tools.TableTool.ModelDataChanged += ModelChangedByTool; Tools.TableTool.ModelDataMoved += ModelDataMovedByTool; Tools.CodeTool.ModelDataChanged += ModelChangedByCodeTool; Tools.CodeTool.ModelDataMoved += ModelDataMovedByTool; scroll.Scheduler = tools; ImplementCommands(); if (changeHistory == null) CascadeScripts(); // if we're sharing history with another viewmodel, our model has already been updated like this. RefreshBackingData(); } public ViewPort(LoadedFile file) : this(file.Name, new BasicModel(file.Contents), InstantDispatch.Instance) { } private void ImplementCommands() { undoWrapper.CanExecute = history.Undo.CanExecute; undoWrapper.Execute = arg => { history.Undo.Execute(arg); using (ModelCacheScope.CreateScope(Model)) tools.RefreshContent(); }; history.Undo.CanExecuteChanged += (sender, e) => undoWrapper.CanExecuteChanged.Invoke(undoWrapper, e); redoWrapper.CanExecute = history.Redo.CanExecute; redoWrapper.Execute = arg => { history.Redo.Execute(arg); using (ModelCacheScope.CreateScope(Model)) tools.RefreshContent(); }; history.Redo.CanExecuteChanged += (sender, e) => redoWrapper.CanExecuteChanged.Invoke(redoWrapper, e); clear.CanExecute = CanAlwaysExecute; clear.Execute = arg => { var selectionStart = scroll.ViewPointToDataIndex(selection.SelectionStart); var selectionEnd = scroll.ViewPointToDataIndex(selection.SelectionEnd); var left = Math.Min(selectionStart, selectionEnd); var right = Math.Max(selectionStart, selectionEnd); var startRun = Model.GetNextRun(left); var endRun = Model.GetNextRun(right); if (startRun == endRun && startRun.Start <= left && (startRun.Start < left || startRun.Start + startRun.Length - 1 > right) && startRun is ITableRun arrayRun) { for (int i = 0; i < arrayRun.ElementCount; i++) { var start = arrayRun.Start + arrayRun.ElementLength * i; if (start + arrayRun.ElementLength <= left) continue; if (start > right) break; for (int j = 0; j < arrayRun.ElementLength; j++) history.CurrentChange.ChangeData(Model, start + j, 0xFF); } } else if (startRun == endRun && (startRun.Start right + 1)) { // clearing _within_ a single run Model.ClearData(history.CurrentChange, left, right - left + 1); } else { Model.ClearFormatAndData(history.CurrentChange, left, right - left + 1); } RefreshBackingData(); }; copy.CanExecute = CanAlwaysExecute; copy.Execute = arg => { var selectionStart = scroll.ViewPointToDataIndex(selection.SelectionStart); var selectionEnd = scroll.ViewPointToDataIndex(selection.SelectionEnd); var left = Math.Min(selectionStart, selectionEnd); var length = Math.Abs(selectionEnd - selectionStart) + 1; if (length > CopyLimit) { OnError?.Invoke(this, $"Cannot copy more than {CopyLimit} bytes at once!"); } else { bool usedHistory = false; if (left + length > Model.Count) { OnError?.Invoke(this, $"Cannot copy beyond the end of the data."); } else if (left < 0) { OnError?.Invoke(this, $"Cannot copy before the start of the data."); } else { ((IFileSystem)arg).CopyText = Model.Copy(() => { usedHistory = true; return history.CurrentChange; }, left, length); RefreshBackingData(); if (usedHistory) UpdateToolsFromSelection(left); } } RequestMenuClose?.Invoke(this, EventArgs.Empty); }; copyAddress.CanExecute = CanAlwaysExecute; copyAddress.Execute = arg => { var fileSystem = (IFileSystem)arg; CopyAddressExecute(fileSystem); }; copyBytes.CanExecute = CanAlwaysExecute; copyBytes.Execute = arg => { var fileSystem = (IFileSystem)arg; CopyBytesExecute(fileSystem); }; deepCopy.CanExecute = CanAlwaysExecute; deepCopy.Execute = arg => { var fileSystem = (IFileSystem)arg; DeepCopyExecute(fileSystem); }; moveSelectionStart.CanExecute = selection.MoveSelectionStart.CanExecute; moveSelectionStart.Execute = arg => { var direction = (Direction)arg; using (ModelCacheScope.CreateScope(Model)) { MoveSelectionStartExecuted(arg, direction); } }; selection.MoveSelectionStart.CanExecuteChanged += (sender, e) => moveSelectionStart.CanExecuteChanged.Invoke(this, e); moveSelectionEnd.CanExecute = selection.MoveSelectionEnd.CanExecute; moveSelectionEnd.Execute = arg => { using (ModelCacheScope.CreateScope(Model)) { selection.MoveSelectionEnd.Execute(arg); } }; selection.MoveSelectionEnd.CanExecuteChanged += (sender, e) => moveSelectionEnd.CanExecuteChanged.Invoke(this, e); isText.CanExecute = CanAlwaysExecute; isText.Execute = IsTextExecuted; save.CanExecute = arg => !history.IsSaved; save.Execute = arg => SaveExecuted((IFileSystem)arg); saveAs.CanExecute = CanAlwaysExecute; saveAs.Execute = arg => SaveAsExecuted((IFileSystem)arg); exportBackup.CanExecute = arg => !history.HasDataChange; exportBackup.Execute = arg => ExportBackupExecuted((IFileSystem)arg); close.CanExecute = CanAlwaysExecute; close.Execute = arg => CloseExecuted((IFileSystem)arg); selectAll.CanExecute = CanAlwaysExecute; selectAll.Execute = arg => SelectAllExecuted(); } /// /// Top-level scripts may be available through metadata. /// Find scripts called by those scripts, and add runs for those too. /// private void CascadeScripts() { var noChange = new NoDataChangeDeltaModel(); using (ModelCacheScope.CreateScope(Model)) { foreach (var run in Runs(Model).OfType().ToList()) { if (run is XSERun) { tools.CodeTool.ScriptParser.FormatScript(noChange, Model, run.Start); } else if (run is BSERun) { tools.CodeTool.BattleScriptParser.FormatScript(noChange, Model, run.Start); } else if (run is ASERun) { tools.CodeTool.AnimationScriptParser.FormatScript(noChange, Model, run.Start); } } } } private static IEnumerable Runs(IDataModel model) { for (var run = model.GetNextRun(0); run.Start < model.Count; run = model.GetNextRun(run.Start + Math.Max(1, run.Length))) { yield return run; } } private void CopyAddressExecute(IFileSystem fileSystem) { var copyText = scroll.ViewPointToDataIndex(selection.SelectionStart).ToString("X6"); fileSystem.CopyText = copyText; RequestMenuClose?.Invoke(this, EventArgs.Empty); OnMessage?.Invoke(this, $"'{copyText}' copied to clipboard."); } private void CopyBytesExecute(IFileSystem fileSystem) { var copyText = GetSelectedByteContents(); fileSystem.CopyText = copyText; RequestMenuClose?.Invoke(this, EventArgs.Empty); OnMessage?.Invoke(this, $"'{copyText}' copied to clipboard."); } private void DeepCopyExecute(IFileSystem fileSystem) { var selectionStart = scroll.ViewPointToDataIndex(selection.SelectionStart); var selectionEnd = scroll.ViewPointToDataIndex(selection.SelectionEnd); var left = Math.Min(selectionStart, selectionEnd); var length = Math.Abs(selectionEnd - selectionStart) + 1; if (length > CopyLimit) { OnError?.Invoke(this, $"Cannot copy more than {CopyLimit} bytes at once!"); } else { bool usedHistory = false; fileSystem.CopyText = Model.Copy(() => { usedHistory = true; return history.CurrentChange; }, left, length, deep: true); RefreshBackingData(); if (usedHistory) UpdateToolsFromSelection(left); } RequestMenuClose?.Invoke(this, EventArgs.Empty); } #endregion private void MoveSelectionStartExecuted(object arg, Direction direction) { var format = this[SelectionStart.X, SelectionStart.Y].Format; if (format is UnderEdit underEdit && underEdit.AutocompleteOptions != null && underEdit.AutocompleteOptions.Count > 0) { int index = -1; for (int i = 0; i < underEdit.AutocompleteOptions.Count; i++) if (underEdit.AutocompleteOptions[i].IsSelected) index = i; var options = default(IReadOnlyList); if (direction == Direction.Up) { index -= 1; if (index < -1) index = underEdit.AutocompleteOptions.Count - 1; options = AutoCompleteSelectionItem.Generate(underEdit.AutocompleteOptions.Select(option => option.CompletionText), index); } else if (direction == Direction.Down) { index += 1; if (index == underEdit.AutocompleteOptions.Count) index = -1; options = AutoCompleteSelectionItem.Generate(underEdit.AutocompleteOptions.Select(option => option.CompletionText), index); } if (options != null) { var edit = new UnderEdit(underEdit.OriginalFormat, underEdit.CurrentText, underEdit.EditWidth, options); currentView[SelectionStart.X, SelectionStart.Y] = new HexElement(this[SelectionStart.X, SelectionStart.Y], edit); NotifyCollectionChanged(ResetArgs); return; } } PreviewScrollChanged?.Invoke(this, EventArgs.Empty); selection.MoveSelectionStart.Execute(arg); } public Point ConvertAddressToViewPoint(int address) => scroll.DataIndexToViewPoint(address); public int ConvertViewPointToAddress(Point p) => scroll.ViewPointToDataIndex(p); public IReadOnlyList GetContextMenuItems(Point selectionPoint) { Debug.Assert(IsSelected(selectionPoint)); var factory = new ContextItemFactory(this); var cell = this[SelectionStart.X, SelectionStart.Y]; (cell?.Format ?? None.Instance).Visit(factory, cell.Value); var results = factory.Results.ToList(); if (!SelectionStart.Equals(SelectionEnd)) { results.Add(new ContextItem("Copy", Copy.Execute) { ShortcutText = "Ctrl+C" }); results.Add(new ContextItem("Deep Copy", DeepCopy.Execute) { ShortcutText = "Ctrl+Shift+C" }); } results.Add(new ContextItem("Paste", arg => Edit(((IFileSystem)arg).CopyText)) { ShortcutText = "Ctrl+V" }); results.Add(new ContextItem("Copy Address", arg => CopyAddressExecute((IFileSystem)arg))); return results; } public bool IsSelected(Point point) => selection.IsSelected(point); public bool IsTable(Point point) { var search = scroll.ViewPointToDataIndex(point); var run = Model.GetNextRun(search); return run.Start <= search && run is ITableRun; } public void Refresh() { scroll.DataLength = Model.Count; RefreshBackingData(); Tools.TableTool.DataForCurrentRunChanged(); Tools.SpriteTool.DataForCurrentRunChanged(); } public bool TryImport(LoadedFile file, IFileSystem fileSystem) { if (file.Name.ToLower().EndsWith(".hma")) { var edit = Encoding.Default.GetString(file.Contents); Edit(edit); return true; } return false; } public void RaiseError(string text) => OnError?.Invoke(this, text); private string deferredMessage; public void RaiseMessage(string text) { // TODO queue multiple messages. deferredMessage = text; tools.Schedule(RaiseMessage); } private void RaiseMessage() => OnMessage?.Invoke(this, deferredMessage); public void ClearAnchor() { var startDataIndex = scroll.ViewPointToDataIndex(SelectionStart); var endDataIndex = scroll.ViewPointToDataIndex(SelectionEnd); if (startDataIndex > endDataIndex) (startDataIndex, endDataIndex) = (endDataIndex, startDataIndex); // do the clear with a custom token that can't change data. // This anchor-clear is a formatting-only change. Model.ClearAnchor(history.InsertCustomChange(new NoDataChangeDeltaModel()), startDataIndex, endDataIndex - startDataIndex + 1); Refresh(); } private readonly IWorkDispatcher dispatcher; public void Edit(string input) { if (!UpdateInProgress) { UpdateInProgress = true; CurrentProgressScopes.Insert(0, tools.DeferUpdates); CurrentProgressScopes.Insert(0, ModelCacheScope.CreateScope(Model)); initialWorkLoad = input.Length; } // allow chunking at newline boundaries only int chunkSize = Math.Max(200, initialWorkLoad / 100); var maxSize = input.Length; if (dispatcher != null && input.Length > chunkSize) { var nextNewline = input.Substring(chunkSize).IndexOf('\n'); if (nextNewline != -1) maxSize = chunkSize + nextNewline + 1; } exitEditEarly = false; int i = 0; try { for (i = 0; i < input.Length && i < maxSize && !exitEditEarly; i++) { if (input[i] == '@' && input.Substring(i).StartsWith("@!game")) skipToNextGameCode = false; if (skipToNextGameCode) { // skip this input } else if (input[i] == '.' && input.Length > i + 6 && input.Substring(i + 1, 5).ToLower() == "thumb") { var lines = input.Substring(i).Split('\n', '\r'); var endLine = lines.Length.Range().FirstOrDefault(j => (lines[j] + " ").ToLower().StartsWith(".end ")); if (endLine == 0) endLine = lines.Length - 1; lines = lines.Take(endLine + 1).ToArray(); var thumbLength = (lines.Length - 1) + lines.Sum(line => line.Length); i += thumbLength - 1; InsertThumbCode(lines); } else { Edit(input[i]); } } } catch { ClearEditWork(); throw; } if (exitEditEarly) { ClearEditWork(); Refresh(); } else if (input.Length > i) { Progress = (double)(initialWorkLoad - input.Length) / initialWorkLoad; dispatcher.DispatchWork(() => Edit(input.Substring(i))); } else { ClearEditWork(); } } public void InsertThumbCode(string[] lines) { var start = ConvertViewPointToAddress(SelectionStart); var result = tools.CodeTool.Parser.Compile(Model, start, lines); for (int i = 0; i < result.Count; i++) CurrentChange.ChangeData(Model, start + i, result[i]); SelectionStart = ConvertAddressToViewPoint(start + result.Count); RefreshBackingData(); } private void ClearEditWork() { CurrentProgressScopes.ForEach(scope => scope.Dispose()); CurrentProgressScopes.Clear(); UpdateInProgress = false; skipToNextGameCode = false; } public void Edit(ConsoleKey key) { using (ModelCacheScope.CreateScope(Model)) { var offset = scroll.ViewPointToDataIndex(GetEditPoint()); var run = Model.GetNextRun(offset); var point = GetEditPoint(); var element = this[point.X, point.Y]; var underEdit = element.Format as UnderEdit; if (key == ConsoleKey.Enter && underEdit != null) { if (underEdit.AutocompleteOptions != null && underEdit.AutocompleteOptions.Any(option => option.IsSelected)) { var selectedIndex = AutoCompleteSelectionItem.SelectedIndex(underEdit.AutocompleteOptions); underEdit = new UnderEdit(underEdit.OriginalFormat, underEdit.AutocompleteOptions[selectedIndex].CompletionText, underEdit.EditWidth); currentView[point.X, point.Y] = new HexElement(element.Value, element.Edited, underEdit); RequestMenuClose?.Invoke(this, EventArgs.Empty); TryCompleteEdit(point); } else { Edit(Environment.NewLine); } return; } if (key == ConsoleKey.Enter && run is ITableRun arrayRun1) { var offsets = arrayRun1.ConvertByteOffsetToArrayOffset(offset); SilentScroll(offsets.SegmentStart + arrayRun1.ElementLength); } if (key == ConsoleKey.Tab && run is ITableRun arrayRun2) { var offsets = arrayRun2.ConvertByteOffsetToArrayOffset(offset); SilentScroll(offsets.SegmentStart + arrayRun2.ElementContent[offsets.SegmentIndex].Length); } if (key == ConsoleKey.Escape) { ClearEdits(SelectionStart); ClearMessage?.Invoke(this, EventArgs.Empty); RequestMenuClose?.Invoke(this, EventArgs.Empty); } if (key != ConsoleKey.Backspace) return; AcceptBackspace(underEdit, element.Value, point); } } public void Autocomplete(string input) { var point = SelectionStart; var element = this[point.X, point.Y]; var underEdit = element.Format as UnderEdit; if (underEdit == null) return; bool tryComplete = true; if (underEdit.AutocompleteOptions != null) { var options = underEdit.AutocompleteOptions; var index = options.Select(option => option.CompletionText).ToList().IndexOf(input); underEdit = new UnderEdit(underEdit.OriginalFormat, options[index].CompletionText, underEdit.EditWidth); tryComplete = options[index].IsFormatComplete; } else { underEdit = new UnderEdit(underEdit.OriginalFormat, input, underEdit.EditWidth); } currentView[point.X, point.Y] = new HexElement(element.Value, element.Edited, underEdit); if (tryComplete) { TryCompleteEdit(point); } else { NotifyCollectionChanged(ResetArgs); // refresh the view } } public void RepointToNewCopy(int pointer) { // if the pointer points to nothing var destinationAddress = Model.ReadPointer(pointer); if (destinationAddress == Pointer.NULL) { CreateNewData(pointer); return; } // if the pointer is expected to point to a type of data, but doesn't var destination = Model.GetNextRun(destinationAddress); var parentRun = Model.GetNextRun(pointer); if (parentRun is ITableRun tableRun) { var offset = tableRun.ConvertByteOffsetToArrayOffset(pointer); if (tableRun.ElementContent[offset.SegmentIndex] is ArrayRunPointerSegment pSegment) { var run = destination; var error = FormatRunFactory.GetStrategy(pSegment.InnerFormat).TryParseData(Model, string.Empty, destinationAddress, ref run); if (error.HasError) { CreateNewData(pointer); return; } } } // if the pointer points to the right type of data, but with no run if (destination.Start != destinationAddress) { RepointWithoutRun(pointer, destinationAddress); return; } if (destination.PointerSources.Count < 2) { OnError?.Invoke(this, "This is the only pointer, no need to make a new copy."); return; } if (destination is ArrayRun) { OnError?.Invoke(this, "Cannot automatically duplicate a table. This operation is unsafe."); return; } var newDestination = Model.FindFreeSpace(destination.Start, destination.Length); if (newDestination == -1) { newDestination = Model.Count; Model.ExpandData(history.CurrentChange, Model.Count + destination.Length); } for (int i = 0; i < destination.Length; i++) { history.CurrentChange.ChangeData(Model, newDestination + i, Model[destination.Start + i]); } Model.ClearPointer(CurrentChange, pointer, destination.Start); Model.WritePointer(CurrentChange, pointer, newDestination); // point to the new destination var destination2 = Model.GetNextRun(destination.Start); Model.ObserveRunWritten(CurrentChange, destination2.Duplicate(newDestination, new SortedSpan(pointer))); // create a new run at the new destination OnMessage?.Invoke(this, "New Copy added at " + newDestination.ToString("X6")); Refresh(); } public void OpenInNewTab(int destination) { var child = new ViewPort(FileName, Model, dispatcher, Singletons, history); child.selection.GotoAddress(destination); RequestTabChange?.Invoke(this, child); } private bool CreateNewData(int pointer) { var errorText = "Can only create new data for a pointer with a format within a table."; if (!(Model.GetNextRun(pointer) is ITableRun tableRun)) { OnError?.Invoke(this, errorText); return false; } var offsets = tableRun.ConvertByteOffsetToArrayOffset(pointer); if (!(tableRun.ElementContent[offsets.SegmentIndex] is ArrayRunPointerSegment pointerSegment) || !pointerSegment.IsInnerFormatValid) { OnError?.Invoke(this, errorText); return false; } var length = FormatRunFactory.GetStrategy(pointerSegment.InnerFormat).LengthForNewRun(Model, pointer); var insert = Model.FindFreeSpace(0, length); if (insert < 0) { insert = Model.Count; Model.ExpandData(CurrentChange, Model.Count + length); scroll.DataLength = Model.Count; } pointerSegment.WriteNewFormat(Model, CurrentChange, pointer, insert, tableRun.ElementContent); RaiseMessage($"New data added at {insert:X6}"); RefreshBackingData(); return true; } /// /// Sometimes, valid data exists in the game but no run could be added. /// In such cases, it could be because a conflict was detected between 2 runs in the data. /// Make a new copy of data, only if we can prove that the data is valid and only if no run is found. /// Leave the original data (and other pointers to it) untouched. /// private void RepointWithoutRun(int source, int destination) { if (!(Model.GetNextRun(source) is ITableRun table)) { RaiseError("Could not parse a data format for that pointer."); return; } var offset = table.ConvertByteOffsetToArrayOffset(source); var segment = table.ElementContent[offset.SegmentIndex] as ArrayRunPointerSegment; if (segment == null) { RaiseError("Could not parse a data format for that pointer."); return; } var strategy = FormatRunFactory.GetStrategy(segment.InnerFormat); if (strategy == null) { RaiseError("Could not parse a data format for that pointer."); return; } IFormattedRun run = new NoInfoRun(destination, new SortedSpan(source)); if (strategy.TryParseData(Model, string.Empty, destination, ref run).HasError) { RaiseError("Could not parse a data format for that pointer."); return; } var newDestination = Model.FindFreeSpace(destination, run.Length); if (newDestination == -1) { newDestination = Model.Count; Model.ExpandData(history.CurrentChange, Model.Count + run.Length); } for (int i = 0; i < run.Length; i++) { history.CurrentChange.ChangeData(Model, newDestination + i, Model[destination + i]); } Model.WritePointer(CurrentChange, source, newDestination); // point to the new destination var newRun = run.Duplicate(newDestination, new SortedSpan(source)); Model.ObserveRunWritten(CurrentChange, newRun); // create a new run at the new destination OnMessage?.Invoke(this, $"Run moved to {newDestination:X6}. This pointer was updated, original data was not modified."); Refresh(); } private void AcceptBackspace(UnderEdit underEdit, byte cellValue, Point point) { // backspace in progress with characters left: just clear a character if (underEdit != null && underEdit.CurrentText.Length > 0) { var newText = underEdit.CurrentText.Substring(0, underEdit.CurrentText.Length - 1); var options = underEdit.AutocompleteOptions; if (options != null) { var selectedIndex = AutoCompleteSelectionItem.SelectedIndex(underEdit.AutocompleteOptions); options = GetAutocompleteOptions(underEdit.OriginalFormat, cellValue, newText, selectedIndex); } var newFormat = new UnderEdit(underEdit.OriginalFormat, newText, underEdit.EditWidth, options); currentView[point.X, point.Y] = new HexElement(this[point.X, point.Y], newFormat); NotifyCollectionChanged(ResetArgs); return; } var index = scroll.ViewPointToDataIndex(point); // backspace on an empty element: clear the data from those cells if (underEdit != null) { var operation = new DataClear(Model, history.CurrentChange, index); var currentValue = index < Model.Count ? Model[index] : (byte)0; underEdit.OriginalFormat.Visit(operation, currentValue); RefreshBackingData(); SelectionStart = scroll.DataIndexToViewPoint(index - 1); point = GetEditPoint(); index = scroll.ViewPointToDataIndex(point); } var run = Model.GetNextRun(index); if (run.Start > index) { // no run: doing a raw edit. SelectionStart = scroll.DataIndexToViewPoint(index); var element = this[SelectionStart.X, SelectionStart.Y]; var text = element.Value.ToString("X2"); currentView[SelectionStart.X, SelectionStart.Y] = new HexElement(element, element.Format.Edit(text.Substring(0, text.Length - 1))); NotifyCollectionChanged(ResetArgs); return; } if (run is PCSRun || run is AsciiRun) { for (int i = index; i < run.Start + run.Length; i++) history.CurrentChange.ChangeData(Model, i, 0xFF); var length = PCSString.ReadString(Model, run.Start, true); if (run is PCSRun) Model.ObserveRunWritten(history.CurrentChange, new PCSRun(Model, run.Start, length, run.PointerSources)); RefreshBackingData(); SelectionStart = scroll.DataIndexToViewPoint(index - 1); return; } var cellToText = new ConvertCellToText(Model, run.Start); var cell = this[point]; var format = cell.Format; if (format is Anchor anchor) format = anchor.OriginalFormat; void TableBackspace(int length) { PrepareForMultiSpaceEdit(point, length); cell.Format.Visit(cellToText, cell.Value); var text = cellToText.Result; if (format is BitArray) { for (int i = 1; i < length; i++) { var extraData = Model[scroll.ViewPointToDataIndex(point) + i]; cell.Format.Visit(cellToText, extraData); text += cellToText.Result; } } text = text.Substring(0, text.Length - 1); currentView[point.X, point.Y] = new HexElement(cell, new UnderEdit(cell.Format, text, length)); } if (run is ITableRun array) { var offsets = array.ConvertByteOffsetToArrayOffset(index); if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.PCS) { for (int i = index + 1; i < offsets.SegmentStart + array.ElementContent[offsets.SegmentIndex].Length; i++) history.CurrentChange.ChangeData(Model, i, 0x00); history.CurrentChange.ChangeData(Model, index, 0xFF); RefreshBackingData(); SelectionStart = scroll.DataIndexToViewPoint(index - 1); } else if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.Pointer) { TableBackspace(4); } else if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.Integer) { TableBackspace(((Integer)format).Length); } else if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.BitArray) { TableBackspace(((BitArray)format).Length); } else { throw new NotImplementedException(); } NotifyCollectionChanged(ResetArgs); return; } if (run is EggMoveRun || run is PLMRun) { PrepareForMultiSpaceEdit(point, 2); cell.Format.Visit(cellToText, cell.Value); var text = cellToText.Result; text = text.Substring(0, text.Length - 1); currentView[point.X, point.Y] = new HexElement(cell, new UnderEdit(cell.Format, text, 2)); NotifyCollectionChanged(ResetArgs); return; } if (run.Start <= index && run.Start + run.Length > index) { // I want to do a backspace at the end of this run SelectionStart = scroll.DataIndexToViewPoint(run.Start); var element = this[SelectionStart.X, SelectionStart.Y]; element.Format.Visit(cellToText, element.Value); var text = cellToText.Result; var editLength = 1; if (element.Format is Pointer) editLength = 4; for (int i = 0; i < run.Length; i++) { var p = scroll.DataIndexToViewPoint(run.Start + i); string editString = i == 0 ? text.Substring(0, text.Length - 1) : string.Empty; if (i > 0) editLength = 1; var newFormat = new UnderEdit(this[p.X, p.Y].Format, editString, editLength); currentView[p.X, p.Y] = new HexElement(this[p.X, p.Y], newFormat); } } NotifyCollectionChanged(ResetArgs); } #region Find public IReadOnlyList<(int start, int end)> Find(string rawSearch) { var results = new List<(int start, int end)>(); var cleanedSearchString = rawSearch.ToUpper(); var searchBytes = new List(); // it might be a string with no quotes, we should check for matches for that. if (cleanedSearchString.Length > 3 && !cleanedSearchString.Contains(StringDelimeter) && !cleanedSearchString.All(AllHexCharacters.Contains)) { results.AddRange(FindUnquotedText(cleanedSearchString, searchBytes)); } // it might be a matched-word var matchedWords = Model.GetMatchedWords(rawSearch); if (matchedWords.Count > 0) { results.AddRange(matchedWords.Select(word => (word, word))); } // it might be a pointer without angle braces if (cleanedSearchString.Length == 6 && cleanedSearchString.All(AllHexCharacters.Contains)) { searchBytes.AddRange(Parse(cleanedSearchString).Reverse().Append((byte)0x08).Select(b => (SearchByte)b)); results.AddRange(Model.Search(searchBytes).Select(result => (result, result + 3))); } // it might be a bl command if (cleanedSearchString.StartsWith("BL ") && cleanedSearchString.Contains("<") && cleanedSearchString.EndsWith(">")) { results.AddRange(FindBranchLink(cleanedSearchString)); } // attempt to parse the search string fully if (TryParseSearchString(searchBytes, cleanedSearchString, errorOnParseError: results.Count == 0)) { // find matches var textResults = Model.Search(searchBytes).Select(result => (result, result + searchBytes.Count - 1)).ToList(); results.AddRange(textResults); // find data matches for the results that are in tables foreach (var result in textResults) { if (Model.GetNextRun(result.result) is ArrayRun parentArray && parentArray.LengthFromAnchor == string.Empty) { results.AddRange(FindMatchingDataResultsFromArrayElement(parentArray, result.result)); } } } // reorder the list to start at the current cursor position results.Sort((a, b) => a.start.CompareTo(b.start)); var offset = scroll.ViewPointToDataIndex(SelectionStart); var left = results.Where(result => result.start < offset); var right = results.Where(result => result.start >= offset); results = right.Concat(left).ToList(); NotifyNumberOfResults(rawSearch, results.Count); return results; } private IEnumerable<(int start, int end)> FindBranchLink(string command) { var addressStart = command.IndexOf(" <") + 2; var addressEnd = command.LastIndexOf(">"); if (addressEnd < addressStart) yield break; var addressText = command.Substring(addressStart, addressEnd - addressStart); if (!int.TryParse(addressText, NumberStyles.HexNumber, CultureInfo.CurrentCulture, out int address)) { address = Model.GetAddressFromAnchor(CurrentChange, -1, addressText); if (address < 0 || address >= Model.Count) yield break; } // I want to know, for any given point in the raw data, if it's possible a branch-link command pointing to `address` // branch link commands are always 4 bytes and have the following format: // 11111 #11 11110 #11, where #=pc+#*2+4 // note that this command is 4 bytes long, stored byte reversed. So in the data, it's: // 8 bits: bits 11-18 of a 22 bit signed offset // 8 bits: // the low 3 bits are bits 19-21 of a 22 bit signed offset // the high 5 bits are always 11110 // 8 bits: bits 0-7 of a 22 bit signed offset // 8 bits: // the low 3 bits are bits 8-10 of a 22 bit signed offset // the high 5 bits are always 11111 // the command is always 2-byte aligned // // bit order is really weird (11-18, 19-21, 0-7, 8-10) because BL is made of **2** instructions, // and each instruction is stored little-endian // start as early as possible in the file: maximum offset, or offset for source=0 int offset = Math.Min(0b0111111111111111111111, (address - 4) / 2); for (; true; offset--) { // traveling down the offsets means traveling up the source options int source = address - 4 - offset * 2; if (source + 4 > Model.RawData.Length) break; if (Model.RawData[source + 2] != (byte)offset) continue; // check source+2 first because it's the simplest, and thus fastest if (Model.RawData[source + 0] != (byte)(offset >> 11)) continue; if (Model.RawData[source + 3] != (0b11111000 | (0b111 & offset >> 8))) continue; if (Model.RawData[source + 1] != (0b11110000 | (0b111 & offset >> 19))) continue; yield return (source, source + 3); } } private IEnumerable<(int start, int end)> FindUnquotedText(string cleanedSearchString, List searchBytes) { var pcsBytes = PCSString.Convert(cleanedSearchString); pcsBytes.RemoveAt(pcsBytes.Count - 1); // remove the 0xFF that was added, since we're searching for a string segment instead of a whole string. // only search for the string if every character in the search string is allowed if (pcsBytes.Count != cleanedSearchString.Length) yield break; searchBytes.AddRange(pcsBytes.Select(b => new PCSSearchByte(b))); var textResults = Model.Search(searchBytes).ToList(); Model.ConsiderResultsAsTextRuns(history.CurrentChange, textResults); foreach (var result in textResults) { if (Model.GetNextRun(result) is ArrayRun parentArray && parentArray.LengthFromAnchor == string.Empty) { foreach (var dataResult in FindMatchingDataResultsFromArrayElement(parentArray, result)) yield return dataResult; } yield return (result, result + pcsBytes.Count - 1); } // it could also be a list token. Look for matches among list enums. foreach (var dataResult in Model.FindListUsages(cleanedSearchString)) yield return dataResult; } /// /// When performing a search, sometimes one of the search results is text from a table. /// If so, then we also care about places where that table value is used. /// This function finds uses of an element in a table. /// private IEnumerable<(int start, int end)> FindMatchingDataResultsFromArrayElement(ArrayRun parentArray, int parentIndex) { var offsets = parentArray.ConvertByteOffsetToArrayOffset(parentIndex); var parentArrayName = Model.GetAnchorFromAddress(-1, parentArray.Start); if (offsets.SegmentIndex == 0 && parentArray.ElementContent[offsets.SegmentIndex].Type == ElementContentType.PCS) { var arrayUses = FindTableUsages(offsets, parentArrayName); var streamUses = FindStreamUsages(offsets, parentArrayName); return arrayUses.Concat(streamUses); } return Enumerable.Empty<(int, int)>(); } private IEnumerable<(int start, int end)> FindTableUsages(ArrayOffset offsets, string parentArrayName) { foreach (var child in Model.All()) { // option 1: another table has a row named after this element if (child is ArrayRun arrayRun && arrayRun.LengthFromAnchor == parentArrayName) { var address = child.Start + child.ElementLength * offsets.ElementIndex; yield return (address, address + child.ElementLength - 1); } // option 2: another table has an enum named after this element var segmentOffset = 0; foreach (var segment in child.ElementContent) { if (!(segment is ArrayRunEnumSegment enumSegment) || enumSegment.EnumName != parentArrayName) { segmentOffset += segment.Length; continue; } for (int i = 0; i < child.ElementCount; i++) { var address = child.Start + child.ElementLength * i + segmentOffset; var enumValue = Model.ReadMultiByteValue(address, segment.Length); if (enumValue != offsets.ElementIndex) continue; yield return (address, address + segment.Length - 1); } segmentOffset += segment.Length; } } } private IEnumerable<(int start, int end)> FindStreamUsages(ArrayOffset offsets, string parentArrayName) { foreach (var child in Model.Streams) { // option 1: the value is used by egg moves if (child is EggMoveRun eggRun) { foreach (var result in eggRun.Search(parentArrayName, offsets.ElementIndex)) yield return result; } // option 2: the value is used by learnable moves if (child is PLMRun plmRun && parentArrayName == HardcodeTablesModel.MoveNamesTable) { foreach (var result in plmRun.Search(offsets.ElementIndex)) yield return result; } // option 3: the value is a move used by trainer teams if (child is TrainerPokemonTeamRun team) { foreach (var result in team.Search(parentArrayName, offsets.ElementIndex)) { yield return (result, result + 1); } } // option 3: the value is in an enum used by a custom table stream if (child is TableStreamRun table) { foreach (var result in table.Search(parentArrayName, offsets.ElementIndex)) yield return result; } } } private void NotifyNumberOfResults(string rawSearch, int results) { if (results == 1) { OnMessage?.Invoke(this, $"Found only 1 match for '{rawSearch}'."); } else if (results > 1) { OnMessage?.Invoke(this, $"Found {results} matches for '{rawSearch}'."); } } private byte[] Parse(string content) { var result = new byte[content.Length / 2]; for (int i = 0; i < result.Length; i++) { var thisByte = content.Substring(i * 2, 2); result[i] += (byte)(AllHexCharacters.IndexOf(thisByte[0]) * 0x10); result[i] += (byte)AllHexCharacters.IndexOf(thisByte[1]); } return result; } private bool TryParseSearchString(List searchBytes, string cleanedSearchString, bool errorOnParseError) { for (int i = 0; i < cleanedSearchString.Length;) { if (cleanedSearchString[i] == ' ') { i++; continue; } if (cleanedSearchString[i] == PointerStart) { if (!TryParsePointerSearchSegment(searchBytes, cleanedSearchString, ref i)) return false; continue; } if (cleanedSearchString[i] == StringDelimeter) { if (TryParseStringSearchSegment(searchBytes, cleanedSearchString, ref i)) continue; } if (cleanedSearchString.Length >= i + 2 && cleanedSearchString.Substring(i, 2) == "XX") { searchBytes.Add(SearchByte.Wild); i += 2; continue; } if (cleanedSearchString.Length >= i + 2 && cleanedSearchString.Substring(i, 2).All(AllHexCharacters.Contains)) { searchBytes.AddRange(Parse(cleanedSearchString.Substring(i, 2)).Select(b => (SearchByte)b)); i += 2; continue; } if (errorOnParseError) OnError?.Invoke(this, $"Could not parse search term {cleanedSearchString.Substring(i)}"); return false; } return true; } private bool TryParsePointerSearchSegment(List searchBytes, string cleanedSearchString, ref int i) { var pointerEnd = cleanedSearchString.IndexOf(PointerEnd, i); if (pointerEnd == -1) { OnError(this, "Search mismatch: no closing >"); return false; } var pointerContents = cleanedSearchString.Substring(i + 1, pointerEnd - i - 1); var address = Model.GetAddressFromAnchor(history.CurrentChange, -1, pointerContents); if (address != Pointer.NULL) { searchBytes.Add((SearchByte)(address >> 0)); searchBytes.Add((SearchByte)(address >> 8)); searchBytes.Add((SearchByte)(address >> 16)); searchBytes.Add((SearchByte)0x08); } else if (pointerContents.All(AllHexCharacters.Contains) && pointerContents.Length <= 6) { searchBytes.AddRange(Parse(pointerContents).Reverse().Append((byte)0x08).Select(b => (SearchByte)b)); } else { OnError(this, $"Could not parse pointer <{pointerContents}>"); return false; } i = pointerEnd + 1; return true; } private bool TryParseStringSearchSegment(List searchBytes, string cleanedSearchString, ref int i) { var endIndex = cleanedSearchString.IndexOf(StringDelimeter, i + 1); while (endIndex > i && cleanedSearchString[endIndex - 1] == '\\') endIndex = cleanedSearchString.IndexOf(StringDelimeter, endIndex + 1); if (endIndex > i) { var pcsBytes = PCSString.Convert(cleanedSearchString.Substring(i, endIndex + 1 - i)); i = endIndex + 1; if (i == cleanedSearchString.Length) pcsBytes.RemoveAt(pcsBytes.Count - 1); searchBytes.AddRange(pcsBytes.Select(b => new PCSSearchByte(b))); return true; } else { return false; } } #endregion public IChildViewPort CreateChildView(int startAddress, int endAddress) { var child = new ChildViewPort(this, dispatcher, Singletons); var run = Model.GetNextRun(startAddress); if (run is ArrayRun array) { var offsets = array.ConvertByteOffsetToArrayOffset(startAddress); var lineStart = array.Start + array.ElementLength * offsets.ElementIndex; child.Goto.Execute(lineStart.ToString("X2")); child.SelectionStart = child.ConvertAddressToViewPoint(startAddress); child.SelectionEnd = child.ConvertAddressToViewPoint(endAddress); } else { child.Goto.Execute(startAddress.ToString("X2")); child.SelectionEnd = child.ConvertAddressToViewPoint(endAddress); } return child; } public void FollowLink(int x, int y) { var format = this[x, y].Format; if (format is Anchor anchor) format = anchor.OriginalFormat; if (format is SpriteDecorator sprite) format = sprite.OriginalFormat; using (ModelCacheScope.CreateScope(Model)) { // follow pointer if (format is Pointer pointer) { if (pointer.Destination != Pointer.NULL) { selection.GotoAddress(pointer.Destination); } else if (string.IsNullOrEmpty(pointer.DestinationName)) { OnError(this, $"null pointers point to nothing, so going to their source isn't possible."); } else { OnError(this, $"Pointer destination {pointer.DestinationName} not found."); } return; } // follow word value source if (format is MatchedWord word) { var address = Model.GetAddressFromAnchor(history.CurrentChange, -1, word.Name.Substring(2)); if (address == Pointer.NULL) { OnError(this, $"No table with name '{word.Name.Substring(2)}' was found."); } else { selection.GotoAddress(address); } return; } // open tool var byteOffset = scroll.ViewPointToDataIndex(new Point(x, y)); var currentRun = Model.GetNextRun(byteOffset); if (currentRun is ISpriteRun) { tools.SpriteTool.SpriteAddress = currentRun.Start; tools.SelectedIndex = Tools.IndexOf(Tools.SpriteTool); } else if (currentRun is IPaletteRun) { tools.SpriteTool.PaletteAddress = currentRun.Start; tools.SelectedIndex = Tools.IndexOf(Tools.SpriteTool); } else if (currentRun is IStreamRun) { Tools.StringTool.Address = currentRun.Start; Tools.SelectedIndex = Tools.IndexOf(Tools.StringTool); } else if (currentRun is ITableRun array) { var offsets = array.ConvertByteOffsetToArrayOffset(byteOffset); if (format is PCS) { Tools.StringTool.Address = offsets.SegmentStart - offsets.ElementIndex * array.ElementLength; Tools.SelectedIndex = Tools.IndexOf(Tools.StringTool); } else { Tools.TableTool.Address = array.Start + offsets.ElementIndex * array.ElementLength; Tools.SelectedIndex = Tools.IndexOf(Tools.TableTool); } } } } public void ExpandSelection(int x, int y) { var index = scroll.ViewPointToDataIndex(SelectionStart); var run = Model.GetNextRun(index); if (run.Start > index) return; if (run is ITableRun array) { var offsets = array.ConvertByteOffsetToArrayOffset(index); if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.Pointer) { FollowLink(x, y); } else { SelectionStart = scroll.DataIndexToViewPoint(offsets.SegmentStart); SelectionEnd = scroll.DataIndexToViewPoint(offsets.SegmentStart + array.ElementContent[offsets.SegmentIndex].Length - 1); } } else if (run is PointerRun) { FollowLink(x, y); } else if (run is IScriptStartRun xse) { var length = tools.CodeTool.ScriptParser.GetScriptSegmentLength(Model, run.Start); if (xse is BSERun) length = tools.CodeTool.BattleScriptParser.GetScriptSegmentLength(Model, run.Start); if (xse is ASERun) length = tools.CodeTool.AnimationScriptParser.GetScriptSegmentLength(Model, run.Start); SelectionStart = scroll.DataIndexToViewPoint(run.Start); SelectionEnd = scroll.DataIndexToViewPoint(run.Start + length - 1); tools.CodeTool.Mode = CodeMode.Script; if (xse is BSERun) tools.CodeTool.Mode = CodeMode.BattleScript; if (xse is ASERun) tools.CodeTool.Mode = CodeMode.AnimationScript; tools.SelectedIndex = tools.IndexOf(tools.CodeTool); } else { SelectionStart = scroll.DataIndexToViewPoint(run.Start); SelectionEnd = scroll.DataIndexToViewPoint(run.Start + run.Length - 1); } if (SelectionStart == SelectionEnd && scroll.ViewPointToDataIndex(SelectionStart) >= run.Start && scroll.ViewPointToDataIndex(SelectionEnd) < run.Start + run.Length) { SelectionStart = scroll.DataIndexToViewPoint(run.Start); SelectionEnd = scroll.DataIndexToViewPoint(run.Start + run.Length - 1); } } public void ConsiderReload(IFileSystem fileSystem) { if (!history.IsSaved) return; // don't overwrite local changes try { var file = fileSystem.LoadFile(FileName); if (file == null) return; // asked to load the file, but the file wasn't found... carry on var metadata = fileSystem.MetadataFor(FileName); Model.Load(file.Contents, metadata != null ? new StoredMetadata(metadata) : null); scroll.DataLength = Model.Count; CascadeScripts(); RefreshBackingData(); // if the new file is shorter, selection might need to be updated // this forces it to be re-evaluated. SelectionStart = SelectionStart; } catch (IOException) { // something happened when we tried to load the file // try again soon. RequestDelayedWork?.Invoke(this, () => ConsiderReload(fileSystem)); } } public virtual void FindAllSources(int x, int y) { var anchor = this[x, y].Format as Anchor; if (anchor == null) return; var title = string.IsNullOrEmpty(anchor.Name) ? (y * Width + x + scroll.DataIndex).ToString("X6") : anchor.Name; title = "Sources of " + title; var newTab = new SearchResultsViewPort(title); foreach (var source in anchor.Sources) newTab.Add(CreateChildView(source, source), source, source); RequestTabChange(this, newTab); RequestMenuClose?.Invoke(this, EventArgs.Empty); } public void OpenSearchResultsTab(string title, IReadOnlyList<(int start, int end)> matches) { if (matches.Count == 1) { var (start, end) = matches[0]; selection.GotoAddress(start); SelectionStart = scroll.DataIndexToViewPoint(start); SelectionEnd = scroll.DataIndexToViewPoint(end); return; } var newTab = new SearchResultsViewPort(title); foreach (var (start, end) in matches) newTab.Add(CreateChildView(start, end), start, end); RequestTabChange(this, newTab); } public void OpenDexReorderTab(string dexTableName) { var newTab = new DexReorderTab(Name, history, Model, dexTableName, HardcodeTablesModel.DexInfoTableName, dexTableName == HardcodeTablesModel.NationalDexTableName); RequestTabChange(this, newTab); } public void OpenImageEditorTab(int address, int spritePage, int palettePage) { var newTab = new ImageEditorViewModel(history, Model, address, Save) { SpritePage = spritePage, PalettePage = palettePage, }; RequestTabChange(this, newTab); } public void ValidateMatchedWords() { // TODO if this is too slow, add a method to the model to get the set of only MatchedWordRuns. for (var run = Model.GetNextRun(0); run != NoInfoRun.NullRun; run = Model.GetNextRun(run.Start + Math.Max(1, run.Length))) { if (!(run is WordRun wordRun)) continue; var address = Model.GetAddressFromAnchor(new NoDataChangeDeltaModel(), -1, wordRun.SourceArrayName); if (address == Pointer.NULL) continue; var array = Model.GetNextRun(address) as ArrayRun; if (array == null) continue; var actualValue = Model.ReadValue(wordRun.Start); if (array.ElementCount == actualValue) continue; OnMessage?.Invoke(this, $"MatchedWord at {wordRun.Start:X6} was expected to have value {array.ElementCount}, but was {actualValue}."); Goto.Execute(wordRun.Start.ToString("X6")); break; } } public void InsertPalette16() { var selectionPoint = scroll.ViewPointToDataIndex(SelectionStart); var run1 = Model.GetNextRun(selectionPoint); var run2 = Model.GetNextRun(selectionPoint + 1); if (run1.Start + 32 != run2.Start || !(run1 is NoInfoRun)) { OnError?.Invoke(this, "Palettes insertion requires a no-format anchor with exactly 32 bytes of space."); return; } for (int i = 0; i < 16; i++) { if (Model.ReadMultiByteValue(run1.Start + i * 2, 2) >= 0x8000) { OnError?.Invoke(this, $"Palette colors only use 15 bits, but the high bit it set at {run1.Start + i * 2 + 1:X6}."); return; } } var currentName = Model.GetAnchorFromAddress(-1, run1.Start); if (string.IsNullOrEmpty(currentName)) currentName = $"{HardcodeTablesModel.DefaultPaletteNamespace}.{run1.Start:X6}"; Model.ObserveAnchorWritten(CurrentChange, currentName, new PaletteRun(run1.Start, new PaletteFormat(4, 1))); Refresh(); UpdateToolsFromSelection(run1.Start); } public void CascadeScript(int address) { Width = Math.Max(Width, 16); // hack to make the width right on initial load Height = Math.Max(Height, 16); // hack to make the height right on initial load var addressText = address.ToString("X6"); Goto.Execute(addressText); Debug.Assert(scroll.DataIndex == address - address % 16); var length = tools.CodeTool.ScriptParser.GetScriptSegmentLength(Model, address); Model.ClearFormat(CurrentChange, address, length - 1); using (ModelCacheScope.CreateScope(Model)) { tools.CodeTool.ScriptParser.FormatScript(CurrentChange, Model, address); } SelectionStart = scroll.DataIndexToViewPoint(address); SelectionEnd = scroll.DataIndexToViewPoint(address + length - 1); tools.CodeTool.Mode = CodeMode.Script; tools.SelectedIndex = tools.IndexOf(tools.CodeTool); } private void Edit(char input) { var point = GetEditPoint(); var element = this[point.X, point.Y]; if (input.IsAny('\r', '\n')) { input = ' '; // handle multiline pasting by just treating the newlines as standard whitespace. withinComment = false; if (element.Format is PCS || element.Format is ErrorPCS) return; // exit early: newlines within strings are ignored, because they're escaped. } if (element.Format is UnderEdit && input == ',') input = ' '; if (!ShouldAcceptInput(point, element, input)) { ClearEdits(point); return; } SelectionStart = point; if (element == this[point.X, point.Y]) { UnderEdit newFormat; if (element.Format is UnderEdit underEdit && underEdit.AutocompleteOptions != null) { var newText = underEdit.CurrentText + input; var autoCompleteOptions = GetAutocompleteOptions(underEdit.OriginalFormat, element.Value, newText); newFormat = new UnderEdit(underEdit.OriginalFormat, newText, underEdit.EditWidth, autoCompleteOptions); } else { newFormat = element.Format.Edit(input.ToString()); } currentView[point.X, point.Y] = new HexElement(element, newFormat); } else { // ShouldAcceptInput already did the work: nothing to change } if (!TryCompleteEdit(point)) { // only need to notify collection changes if we didn't complete an edit NotifyCollectionChanged(ResetArgs); } } private IReadOnlyList GetAutocompleteOptions(IDataFormat originalFormat, byte value, string newText, int selectedIndex = -1) { using (ModelCacheScope.CreateScope(Model)) { var visitor = new AutocompleteCell(Model, newText, selectedIndex); (originalFormat ?? Undefined.Instance).Visit(visitor, value); return visitor.Result; } } private void ClearEdits(Point point) { if (this[point.X, point.Y].Format is UnderEdit) RefreshBackingData(); withinComment = false; } private Point GetEditPoint() { var selectionStart = scroll.ViewPointToDataIndex(SelectionStart); var selectionEnd = scroll.ViewPointToDataIndex(SelectionEnd); var leftEdge = Math.Min(selectionStart, selectionEnd); var point = scroll.DataIndexToViewPoint(leftEdge); scroll.ScrollToPoint(ref point); return point; } private void IsTextExecuted(object notUsed) { var selectionStart = scroll.ViewPointToDataIndex(selection.SelectionStart); var selectionEnd = scroll.ViewPointToDataIndex(selection.SelectionEnd); var left = Math.Min(selectionStart, selectionEnd); var length = Math.Abs(selectionEnd - selectionStart) + 1; var startPlaces = Model.FindPossibleTextStartingPlaces(left, length); // do the actual search now that we know places to start var foundCount = Model.ConsiderResultsAsTextRuns(history.CurrentChange, startPlaces); if (foundCount == 0) { OnError?.Invoke(this, "Failed to automatically find text at that location."); } else { RefreshBackingData(); } RequestMenuClose?.Invoke(this, EventArgs.Empty); } private bool ShouldAcceptInput(Point point, HexElement element, char input) { var memoryLocation = scroll.ViewPointToDataIndex(point); // special cases: if there's no edit unde way, there's a few formats that can be added anywhere. Handle those first. if (!(element.Format is UnderEdit)) { if (input == ExtendArray) { var index = scroll.ViewPointToDataIndex(point); if (Model.IsAtEndOfArray(index, out var _)) return true; } if (input == AnchorStart || input == GotoMarker) { // anchor edits are actually 0 length // but lets give them 4 spaces to work with PrepareForMultiSpaceEdit(point, 4); var autoCompleteOptions = input == GotoMarker ? new AutoCompleteSelectionItem[0] : null; var underEdit = new UnderEdit(element.Format, input.ToString(), 4, autoCompleteOptions); currentView[point.X, point.Y] = new HexElement(element, underEdit); return true; } if (input == CommentStart) { var underEdit = new UnderEdit(element.Format, input.ToString()); currentView[point.X, point.Y] = new HexElement(element, underEdit); withinComment = true; return true; } } // normal case: the logic for how to handle this edit depends on what format is in this cell. var startCellEdit = new StartCellEdit(Model, memoryLocation, input); element.Format.Visit(startCellEdit, element.Value); if (startCellEdit.NewFormat != null) { // if the edit provided a new format, go ahead and build a new element based on that format. // if no new format was provided, then the default logic in the method above will make a new UnderEdit cell if the Result is true. currentView[point.X, point.Y] = new HexElement(element, startCellEdit.NewFormat); if (startCellEdit.NewFormat.EditWidth > 1) PrepareForMultiSpaceEdit(point, startCellEdit.NewFormat.EditWidth); } return startCellEdit.Result; } private (Point start, Point end) GetSelectionSpan(Point p) { var index = scroll.ViewPointToDataIndex(p); var run = Model.GetNextRun(index); if (run.Start > index) return (p, p); (Point, Point) pair(int start, int end) => (scroll.DataIndexToViewPoint(start), scroll.DataIndexToViewPoint(end)); var format = run.CreateDataFormat(Model, index); while (format is IDataFormatDecorator decorator) format = decorator.OriginalFormat; if (format is IDataFormatInstance instance) { return pair(instance.Source, instance.Source + instance.Length - 1); } if (!(run is ITableRun array)) return (p, p); var naturalEnd = array.Start + array.ElementCount * array.ElementLength; if (naturalEnd <= index) { return pair(naturalEnd, array.Start + array.Length - 1); } var offset = array.ConvertByteOffsetToArrayOffset(index); var type = array.ElementContent[offset.SegmentIndex].Type; if (type == ElementContentType.Pointer || type == ElementContentType.Integer || type == ElementContentType.BitArray) { return pair(offset.SegmentStart, offset.SegmentStart + array.ElementContent[offset.SegmentIndex].Length - 1); } return (p, p); } private void PrepareForMultiSpaceEdit(Point point, int length) { var index = scroll.ViewPointToDataIndex(point); var endIndex = index + length - 1; for (int i = 1; i < length; i++) { point = scroll.DataIndexToViewPoint(index + i); if (point.Y >= Height) return; var element = this[point.X, point.Y]; var newFormat = element.Format.Edit(string.Empty); currentView[point.X, point.Y] = new HexElement(element, newFormat); } selection.PropertyChanged -= SelectionPropertyChanged; // don't notify on multi-space edit: it breaks up the undo history SelectionEnd = scroll.DataIndexToViewPoint(endIndex); selection.PropertyChanged += SelectionPropertyChanged; } private bool TryCompleteEdit(Point point) { // wrap this whole method in an anti-recursion clause selection.PreviewSelectionStartChanged -= ClearActiveEditBeforeSelectionChanges; using (new StubDisposable { Dispose = () => selection.PreviewSelectionStartChanged += ClearActiveEditBeforeSelectionChanges }) { var element = this[point.X, point.Y]; var underEdit = element.Format as UnderEdit; if (underEdit == null) return false; // no edit to complete if (TryGeneralCompleteEdit(underEdit.CurrentText, point, out bool result)) { return result; } // normal case: whether or not to accept the edit depends on the existing cell format var dataIndex = scroll.ViewPointToDataIndex(point); var completeEditOperation = new CompleteCellEdit(Model, dataIndex, underEdit.CurrentText, history.CurrentChange); using (ModelCacheScope.CreateScope(Model)) { (underEdit.OriginalFormat ?? Undefined.Instance).Visit(completeEditOperation, element.Value); if (completeEditOperation.Result) { // if the data we just changed was in a table, notify children of that table about the change if (Model.GetNextRun(dataIndex) is ITableRun tableRun) { var offsets = tableRun.ConvertByteOffsetToArrayOffset(dataIndex); var errorInfo = tableRun.NotifyChildren(Model, history.CurrentChange, offsets.ElementIndex, offsets.SegmentIndex); HandleErrorInfo(errorInfo); } // update the cell / selection if (completeEditOperation.NewCell != null) { currentView[point.X, point.Y] = completeEditOperation.NewCell; } if (completeEditOperation.DataMoved || completeEditOperation.NewDataIndex > scroll.DataLength) scroll.DataLength = Model.Count; // update tools from the new moved selection var run = Model.GetNextRun(completeEditOperation.NewDataIndex); if (run.Start > completeEditOperation.NewDataIndex) run = new NoInfoRun(Model.Count); if (completeEditOperation.DataMoved) UpdateToolsFromSelection(run.Start); if (run is ITableRun) { Tools.Schedule(Tools.TableTool.DataForCurrentRunChanged); } if (run is ITableRun || run is IStreamRun) Tools.Schedule(Tools.StringTool.DataForCurrentRunChanged); if (run is ISpriteRun || run is IPaletteRun) { tools.Schedule(tools.SpriteTool.DataForCurrentRunChanged); tools.Schedule(tools.TableTool.DataForCurrentRunChanged); } if (completeEditOperation.MessageText != null) OnMessage?.Invoke(this, completeEditOperation.MessageText); if (completeEditOperation.ErrorText != null) OnError?.Invoke(this, completeEditOperation.ErrorText); // refresh the screen if (!SilentScroll(completeEditOperation.NewDataIndex) && completeEditOperation.NewCell == null) { RefreshBackingData(); } UpdateToolsFromSelection(completeEditOperation.NewDataIndex); } } return completeEditOperation.Result; } } /// /// Some edits are valid no matter where you are in the data. /// Try to complete one of those edits here. /// Return true if it's a special edit. Result is true if the edit was completed. /// private bool TryGeneralCompleteEdit(string currentText, Point point, out bool result) { result = false; // goto marker if (currentText.StartsWith(GotoMarker.ToString())) { if (currentText.Length == 2 && currentText[1] == '{') { var currentAddress = scroll.ViewPointToDataIndex(point); var destination = Model.ReadPointer(currentAddress); if (destination == Pointer.NULL) { if (CreateNewData(currentAddress)) { destination = Model.ReadPointer(currentAddress); } else { OnError?.Invoke(this, $"Could not jump using pointer at {currentAddress:X6}"); } } ClearEdits(point); if (destination >= 0 && destination < Model.Count) { using (ChangeHistory.ContinueCurrentTransaction()) { Goto.Execute(destination); selection.SetJumpBackPoint(currentAddress + 4); } } else if (destination != Pointer.NULL) { OnError?.Invoke(this, $"Could not jump using pointer at {currentAddress:X6}"); } RequestMenuClose?.Invoke(this, EventArgs.Empty); result = true; } else if (currentText.Length == 2 && currentText[1] == '}') { ClearEdits(point); using (ChangeHistory.ContinueCurrentTransaction()) selection.Back.Execute(); RequestMenuClose?.Invoke(this, EventArgs.Empty); result = true; } if (char.IsWhiteSpace(currentText[currentText.Length - 1])) { var destination = currentText.Substring(1).Trim(); ClearEdits(point); if (currentText.Contains("=")) { UpdateConstant(destination); } else { var parts = destination.Split(CommandMarker); if (!string.IsNullOrWhiteSpace(parts[0])) Goto.Execute(parts[0]); for (int i = 1; i < parts.Length; i++) ExecuteMetacommand(parts[i]); } RequestMenuClose?.Invoke(this, EventArgs.Empty); result = true; } return true; } // comment if (currentText.StartsWith(CommentStart.ToString())) { if (currentText.Length > 1 && currentText.EndsWith(CommentStart.ToString())) withinComment = false; result = (currentText.EndsWith(" ") || currentText.EndsWith("#")) && !withinComment; if (result) ClearEdits(point); return true; } // anchor start if (currentText.StartsWith(AnchorStart.ToString())) { TryUpdate(ref anchorText, currentText, nameof(AnchorText)); var endingCharacter = currentText[currentText.Length - 1]; // anchor format will only end once the user // -> types a whitespace character, // -> types a closing quote for the text format "" // -> types a closing ` for a `` format if (!char.IsWhiteSpace(endingCharacter) && !currentText.EndsWith(AsciiRun.StreamDelimeter.ToString()) && !currentText.EndsWith(PCSRun.StringDelimeter.ToString())) { AnchorTextVisible = true; return true; } // special case: `asc` has a length token outside the ``, so the anchor isn't completed if it ends with `asc` if (currentText.EndsWith("`asc`")) { AnchorTextVisible = true; return true; } // only end the anchor edit if the [] brace count matches if (currentText.Sum(c => c == '[' ? 1 : c == ']' ? -1 : 0) != 0) { AnchorTextVisible = true; return true; } // only end the anchor if the "" and `` quote count are even if (currentText.Count(AsciiRun.StreamDelimeter) % 2 != 0 || currentText.Count(StringDelimeter) % 2 != 0) { AnchorTextVisible = true; return true; } if (!CompleteAnchorEdit(point)) exitEditEarly = true; result = true; return true; } // directive marker var element = this[point.X, point.Y]; var underEdit = element.Format as UnderEdit; if (currentText.StartsWith(DirectiveMarker.ToString()) && currentText.Count(c => c == DirectiveMarker) == 1) { if (underEdit.OriginalFormat is PCS || underEdit.OriginalFormat is Ascii) { // if we're in a text cell, don't allow directives. } else { result = CompleteDirectiveEdit(point, currentText); return true; } } // table extension var dataIndex = scroll.ViewPointToDataIndex(point); if (currentText == ExtendArray.ToString() && Model.IsAtEndOfArray(dataIndex, out var arrayRun)) { var originalArray = arrayRun; var errorInfo = Model.CompleteArrayExtension(history.CurrentChange, 1, ref arrayRun); if (!errorInfo.HasError || errorInfo.IsWarning) { if (arrayRun != null && arrayRun.Start != originalArray.Start) { ScrollFromTableMove(dataIndex, originalArray, arrayRun); } RefreshBackingData(); SelectionEnd = GetSelectionSpan(SelectionStart).end; } HandleErrorInfo(errorInfo); result = true; return true; } return false; } private void UpdateConstant(string expression) { var parts = expression.Split('='); if (parts.Length != 2) { RaiseError("Could not parse constant assignment expression."); return; } if (!int.TryParse(parts[1], out var value)) { RaiseError("Could not parse constant assignment expression."); return; } var locations = Model.GetMatchedWords(parts[0]); if (locations == null || locations.Count == 0) { RaiseError($"{parts[0]} is not a named constant!"); return; } foreach (var address in locations) { if (!(Model.GetNextRun(address) is WordRun currentRun)) continue; var writeValue = value + currentRun.ValueOffset; if (writeValue < 0 || writeValue > 255) { RaiseError($"{currentRun.Start:X6}: value out of range!"); } Model.WriteMultiByteValue(address, currentRun.Length, CurrentChange, writeValue); } } /// /// Current Available metacommands: /// lz(1024) -> write 1024 compressed bytes. Error if we're not in freespace, do nothing if we're at lz data of the expected length already. /// 00(32) -> Write 32 bytes of zero. Error if we're not in freespace (FF). /// * Does not error if clearing a subset (or entire) table, so long as the clear matches a multiple of a row length. Still clears that data though. /// * Does not error if clearing exactly the length of a non-table run. Don't clear the data either: all 0's may not be valid (such as with strings) /// put(1234)-> put the bytes 12, then 34, at the current location, but don't change the current selection. /// works no matter what the current data is. /// private void ExecuteMetacommand(string command) { command = command.ToLower(); var index = scroll.ViewPointToDataIndex(SelectionStart); var paramsStart = command.IndexOf("("); var paramsEnd = command.IndexOf(")"); var length = 0; if (command.StartsWith("lz(") && paramsEnd > 3 && int.TryParse(command.Substring(3, paramsEnd - 3), out length)) { // only do the write if the current data isn't compressed data of the right length var existingCompressedData = LZRun.Decompress(Model, index); var newCompressed = LZRun.Compress(new byte[length], 0, length); if (existingCompressedData != null && existingCompressedData.Length == length) { // do nothing, it's already the right data type } else if (newCompressed.Count.Range().All(i => Model[index + i] == 0xFF)) { // data is all FF, go ahead and write for (int i = 0; i < newCompressed.Count; i++) CurrentChange.ChangeData(Model, index + i, newCompressed[i]); } else { // data is not freespace and is not the correct data: error RaiseError($"Writing {length} compressed bytes would overwrite existing data."); exitEditEarly = true; } } else if (command.StartsWith("00(") && paramsEnd > 3 && int.TryParse(command.Substring(3, paramsEnd - 3), out length)) { var currentRun = Model.GetNextRun(index); var tableRun = currentRun as ITableRun; if (tableRun != null && currentRun.Start == index && length % tableRun.ElementLength == 0 && length <= tableRun.Length) { // we're trying to clear out table data. // assume that the user wanted us to clear it. // do NOT do the clear if the current clear is bigger than the current table: that could wipe existing data. ClearPointersFromTable(tableRun, index, length); for (int i = 0; i < length; i++) CurrentChange.ChangeData(Model, index + i, 0); } else if (tableRun == null && currentRun.Start == index && currentRun.Length == length) { // we're trying to clear out a non-table // the length matches exactly, so we shouldn't error. But also, don't actually clear. } else if (length.Range().All(i => Model[index + i] == 0xFF)) { for (int i = 0; i < length; i++) CurrentChange.ChangeData(Model, index + i, 0); } else { RaiseError($"Writing {length} 00 bytes would overwrite existing data."); exitEditEarly = true; } } else if (command.StartsWith("game(") && paramsEnd > 5) { var content = command.Substring(5, paramsEnd - 5).ToLower(); var gameCode = Model.GetGameCode().ToLower(); if (content == "all") { // all good } else if (!content.Contains(gameCode)) { skipToNextGameCode = true; } } else if (command.StartsWith("put(") && paramsEnd > 4) { var content = command.Substring(4, paramsEnd - 4); if (content.Length % 2 != 0 || !content.All(AllHexCharacters.Contains)) { RaiseError("'put' expects hex bytes as an argument. "); exitEditEarly = true; return; } for (int i = 0; i < content.Length / 2; i++) { var data = byte.Parse(content.Substring(i * 2, 2), NumberStyles.HexNumber); CurrentChange.ChangeData(Model, index + i, data); } } else { RaiseError($"Could not parse metacommand {command}."); exitEditEarly = true; } } private void ClearPointersFromTable(ITableRun tableRun, int index, int length) { foreach (var segment in tableRun.ElementContent) { if (segment.Type != ElementContentType.Pointer) continue; var offset = tableRun.ElementContent.Until(seg => seg == segment).Sum(seg => seg.Length); for (int i = offset; i < length; i += tableRun.ElementLength) { var destination = Model.ReadPointer(tableRun.Start + i); Model.ClearPointer(CurrentChange, tableRun.Start + i, destination); } } } /// True if it was completed successfully, false if some sort of error occurred and we should abort the remainder of the edit. private bool CompleteAnchorEdit(Point point) { var underEdit = (UnderEdit)this[point.X, point.Y].Format; var index = scroll.ViewPointToDataIndex(point); ErrorInfo errorInfo; // if it's an unnamed text/stream anchor, we have special logic for that using (ModelCacheScope.CreateScope(Model)) { if (underEdit.CurrentText.Trim() == AnchorStart + PCSRun.SharedFormatString) { int count = Model.ConsiderResultsAsTextRuns(history.CurrentChange, new[] { index }); if (count == 0) { errorInfo = new ErrorInfo("An anchor with nothing pointing to it must have a name."); } else { errorInfo = ErrorInfo.NoError; } } else if (underEdit.CurrentText == AnchorStart + PLMRun.SharedFormatString) { if (!PokemonModel.ConsiderAsPlmStream(Model, index, history.CurrentChange)) { errorInfo = new ErrorInfo("An anchor with nothing pointing to it must have a name."); } else { errorInfo = ErrorInfo.NoError; Tools.StringTool.RefreshContentAtAddress(); } } else if (underEdit.CurrentText == AnchorStart + XSERun.SharedFormatString) { // TODO CascadeScript(index); errorInfo = ErrorInfo.NoError; } else { errorInfo = PokemonModel.ApplyAnchor(Model, history.CurrentChange, index, underEdit.CurrentText); Tools.StringTool.RefreshContentAtAddress(); } } ClearEdits(point); UpdateToolsFromSelection(index); if (errorInfo == ErrorInfo.NoError) { if (Model.GetNextRun(index) is ArrayRun array && array.Start == index) Goto.Execute(index.ToString("X2")); return true; } HandleErrorInfo(errorInfo); return errorInfo.IsWarning; } private bool CompleteDirectiveEdit(Point point, string currentText) { currentText = currentText.ToLower(); if (!currentText.EndsWith(" ")) return false; if (currentText.StartsWith(".align ") && currentText.Length > 8) { if (!int.TryParse(currentText.Substring(7), out int value)) value = 4; ClearEdits(point); var index = scroll.ViewPointToDataIndex(point); if (value == 2) SelectionStart = scroll.DataIndexToViewPoint(index + point.X % 2); if (value == 4 && point.X % 4 != 0) SelectionStart = scroll.DataIndexToViewPoint(index + 4 - (point.X % 4)); } if (currentText.StartsWith(".text")) ClearEdits(point); if (currentText.StartsWith(".thumb")) ClearEdits(point); return false; } private void ScrollFromTableMove(int initialSelection, ITableRun oldRun, ITableRun newRun) { scroll.DataLength = Model.Count; // possible length change var tableOffset = scroll.DataIndex - oldRun.Start; var relativeSelection = initialSelection - oldRun.Start; selection.PropertyChanged -= SelectionPropertyChanged; selection.GotoAddress(newRun.Start + tableOffset); selection.SelectionStart = scroll.DataIndexToViewPoint(newRun.Start + relativeSelection); selection.PropertyChanged += SelectionPropertyChanged; } private bool SilentScroll(int memoryLocation) { var nextPoint = scroll.DataIndexToViewPoint(memoryLocation); var didScroll = true; if (!scroll.ScrollToPoint(ref nextPoint)) { // only need to notify collection change if we didn't auto-scroll after changing cells NotifyCollectionChanged(ResetArgs); didScroll = false; } UpdateSelectionWithoutNotify(nextPoint); return didScroll; } public void HandleErrorInfo(ErrorInfo info) { if (!info.HasError) return; if (info.IsWarning) OnMessage?.Invoke(this, info.ErrorMessage); else OnError?.Invoke(this, info.ErrorMessage); } /// /// When automatically updating the selection, /// update it without notifying ourselves. /// This lets us tell the difference between a manual cell change and an auto-cell change, /// which is useful for deciding change history boundaries. /// private void UpdateSelectionWithoutNotify(Point nextPoint) { selection.PropertyChanged -= SelectionPropertyChanged; SelectionStart = nextPoint; NotifyPropertyChanged(nameof(SelectionStart)); NotifyPropertyChanged(nameof(SelectionEnd)); selection.PropertyChanged += SelectionPropertyChanged; } private void ModelChangedByTool(object sender, IFormattedRun run) { if (run.Start < scroll.ViewPointToDataIndex(new Point(Width - 1, Height - 1)) || run.Start + run.Length > scroll.DataIndex) { // there's some visible data that changed RefreshBackingData(); } if (run is ITableRun && sender != Tools.StringTool && Model.GetNextRun(Tools.StringTool.Address).Start == run.Start) Tools.StringTool.DataForCurrentRunChanged(); if (run is ITableRun && Model.GetNextRun(Tools.TableTool.Address).Start == run.Start) Tools.TableTool.DataForCurrentRunChanged(); } private void ModelDataMovedByTool(object sender, (int originalLocation, int newLocation) locations) { scroll.DataLength = Model.Count; if (scroll.DataIndex <= locations.originalLocation && locations.originalLocation < scroll.ViewPointToDataIndex(new Point(Width - 1, Height - 1))) { // data was moved from onscreen: follow it int offset = locations.originalLocation - scroll.DataIndex; selection.GotoAddress(locations.newLocation - offset); } RaiseMessage($"Data was automatically moved to {locations.newLocation:X6}. Pointers were updated."); } private void ModelChangedByCodeTool(object sender, ErrorInfo e) { RefreshBackingData(); HandleErrorInfo(e); } private void RefreshBackingData(Point p) { var index = scroll.ViewPointToDataIndex(p); var edited = Model.HasChanged(index); if (index < 0 | index >= Model.Count) { currentView[p.X, p.Y] = HexElement.Undefined; return; } var run = Model.GetNextRun(index); if (index < run.Start) { currentView[p.X, p.Y] = new HexElement(Model[index], edited, None.Instance); return; } var format = run.CreateDataFormat(Model, index); format = Model.WrapFormat(run, format, index); currentView[p.X, p.Y] = new HexElement(Model[index], edited, format); } private void RefreshBackingData() { currentView = new HexElement[Width, Height]; RequestMenuClose?.Invoke(this, EventArgs.Empty); NotifyCollectionChanged(ResetArgs); NotifyPropertyChanged(nameof(FreeSpaceStart)); } private void RefreshBackingDataFull() { currentView = new HexElement[Width, Height]; IFormattedRun run = null; using (ModelCacheScope.CreateScope(Model)) { for (int y = 0; y < Height; y++) { for (int x = 0; x < Width; x++) { var index = scroll.ViewPointToDataIndex(new Point(x, y)); var edited = Model.HasChanged(index); if (run == null || index >= run.Start + run.Length) { run = Model.GetNextRun(index) ?? new NoInfoRun(Model.Count); } if (index < 0 || index >= Model.Count) { currentView[x, y] = HexElement.Undefined; } else if (index >= run.Start) { var format = run is BaseRun baseRun ? baseRun.CreateDataFormat(Model, index, x == 0, Width) : run.CreateDataFormat(Model, index); format = Model.WrapFormat(run, format, index); currentView[x, y] = new HexElement(Model[index], edited, format); } else { currentView[x, y] = new HexElement(Model[index], edited, None.Instance); } } } } } private void UpdateColumnHeaders() { var index = scroll.ViewPointToDataIndex(new Point(0, 0)); var run = Model.GetNextRun(index) as ArrayRun; if (run != null && run.Start > index) run = null; // only use the run if it starts _before_ the screen var headers = run?.GetColumnHeaders(Width, index) ?? HeaderRow.GetDefaultColumnHeaders(Width, index); for (int i = 0; i < headers.Count; i++) { if (i < ColumnHeaders.Count) ColumnHeaders[i] = headers[i]; else ColumnHeaders.Add(headers[i]); } while (ColumnHeaders.Count > headers.Count) ColumnHeaders.RemoveAt(ColumnHeaders.Count - 1); } private void NotifyCollectionChanged(NotifyCollectionChangedEventArgs args) => CollectionChanged?.Invoke(this, args); } }