diff --git a/src/HexManiac.Core/HexManiac.Core.csproj b/src/HexManiac.Core/HexManiac.Core.csproj index ab544a3e..c60b63a4 100644 --- a/src/HexManiac.Core/HexManiac.Core.csproj +++ b/src/HexManiac.Core/HexManiac.Core.csproj @@ -52,6 +52,7 @@ + diff --git a/src/HexManiac.Core/Models/AutoSearchModel.cs b/src/HexManiac.Core/Models/AutoSearchModel.cs index 4e086a2a..5cc52945 100644 --- a/src/HexManiac.Core/Models/AutoSearchModel.cs +++ b/src/HexManiac.Core/Models/AutoSearchModel.cs @@ -38,6 +38,7 @@ namespace HavenSoft.HexManiac.Core.Models { DecodeHeader(); DecodeNameArrays(); DecodeDataArrays(); + DecodeStreams(); } } @@ -54,12 +55,12 @@ namespace HavenSoft.HexManiac.Core.Models { private void DecodeNameArrays() { // movenames if (TrySearch(this, noChangeDelta, "[name\"\"13]", out var movenames)) { - ObserveAnchorWritten(noChangeDelta, "movenames", movenames); + ObserveAnchorWritten(noChangeDelta, EggMoveRun.MoveNamesTable, movenames); } // pokenames if (TrySearch(this, noChangeDelta, "[name\"\"11]", out var pokenames)) { - ObserveAnchorWritten(noChangeDelta, "pokenames", pokenames); + ObserveAnchorWritten(noChangeDelta, EggMoveRun.PokemonNameTable, pokenames); } // abilitynames / trainer names @@ -116,5 +117,11 @@ namespace HavenSoft.HexManiac.Core.Models { // @3D4294 ^itemicons[image<> palette<>]items // @4886E8 ^movedescriptions[description<>]354 } + + private void DecodeStreams() { + if (EggMoveRun.TrySearch(this, noChangeDelta, out var eggmoves)) { + ObserveAnchorWritten(noChangeDelta, "eggmoves", eggmoves); + } + } } } diff --git a/src/HexManiac.Core/Models/IDataModel.cs b/src/HexManiac.Core/Models/IDataModel.cs index 56d73380..f9e15cbe 100644 --- a/src/HexManiac.Core/Models/IDataModel.cs +++ b/src/HexManiac.Core/Models/IDataModel.cs @@ -11,6 +11,7 @@ namespace HavenSoft.HexManiac.Core.Models { byte[] RawData { get; } new byte this[int index] { get; set; } IReadOnlyList Arrays { get; } + IReadOnlyList Streams { get; } /// /// If dataIndex is in the middle of a run, returns that run. @@ -64,6 +65,7 @@ namespace HavenSoft.HexManiac.Core.Models { public BaseModel(byte[] data) => RawData = data; public virtual IReadOnlyList Arrays { get; } = new List(); + public virtual IReadOnlyList Streams { get; } = new List(); public byte this[int index] { get => RawData[index]; set => RawData[index] = value; } diff --git a/src/HexManiac.Core/Models/PokemonModel.cs b/src/HexManiac.Core/Models/PokemonModel.cs index 64b19dc0..b59127a0 100644 --- a/src/HexManiac.Core/Models/PokemonModel.cs +++ b/src/HexManiac.Core/Models/PokemonModel.cs @@ -29,6 +29,7 @@ namespace HavenSoft.HexManiac.Core.Models { public virtual int EarliestAllowedAnchor => 0; public override IReadOnlyList Arrays => runs.OfType().ToList(); + public override IReadOnlyList Streams => runs.OfType().ToList(); #region Constructor @@ -738,7 +739,7 @@ namespace HavenSoft.HexManiac.Core.Models { public override string Copy(Func changeToken, int start, int length) { var text = new StringBuilder(); var run = GetNextRun(start); - if (run.Start < start && !(run is ArrayRun)) { + if (run.Start < start && !(run is ArrayRun) && !(run is EggMoveRun)) { length += start - run.Start; start = run.Start; } @@ -784,6 +785,11 @@ namespace HavenSoft.HexManiac.Core.Models { text.Append(" "); length -= run.Start + run.Length - start; start = run.Start + run.Length; + } else if (run is EggMoveRun eggRun) { + eggRun.AppendTo(text, start, length); + text.Append(" "); + length -= run.Start + run.Length - start; + start = run.Start + run.Length; } else { throw new NotImplementedException(); } @@ -979,6 +985,8 @@ namespace HavenSoft.HexManiac.Core.Models { } else { return new ErrorInfo($"Ascii runs must include a length."); } + } else if (format.StartsWith(StreamDelimeter + "egg" + StreamDelimeter)) { + run = new EggMoveRun(model, dataIndex); } else { var errorInfo = TryParse(model, format, dataIndex, null, out var arrayRun); if (errorInfo == ErrorInfo.NoError) { @@ -1083,6 +1091,8 @@ namespace HavenSoft.HexManiac.Core.Models { newRun = new PCSRun(newStart, run.Length, run.PointerSources); } else if (run is ArrayRun array) { newRun = array.Move(newStart); + } else if (run is EggMoveRun egg) { + newRun = new EggMoveRun(this, newStart); } else { throw new NotImplementedException(); } diff --git a/src/HexManiac.Core/Models/Runs/ArrayRunElementSegment.cs b/src/HexManiac.Core/Models/Runs/ArrayRunElementSegment.cs index 656fead1..af34e9f8 100644 --- a/src/HexManiac.Core/Models/Runs/ArrayRunElementSegment.cs +++ b/src/HexManiac.Core/Models/Runs/ArrayRunElementSegment.cs @@ -116,8 +116,12 @@ namespace HavenSoft.HexManiac.Core.Models.Runs { private IReadOnlyList cachedOptions; public IReadOnlyList GetOptions(IDataModel model) { if (cachedOptions != null) return cachedOptions; + cachedOptions = GetOptions(model, EnumName); + return cachedOptions; + } - if (!model.TryGetNameArray(EnumName, out var enumArray)) return null; + public static IReadOnlyList GetOptions(IDataModel model, string enumName) { + if (!model.TryGetNameArray(enumName, out var enumArray)) return null; // array must be at least as long as than the current value var optionCount = enumArray.ElementCount; @@ -139,7 +143,6 @@ namespace HavenSoft.HexManiac.Core.Models.Runs { results.Add(value); } - cachedOptions = results; return results; } diff --git a/src/HexManiac.Core/Models/Runs/EggMoveRun.cs b/src/HexManiac.Core/Models/Runs/EggMoveRun.cs new file mode 100644 index 00000000..6a22b867 --- /dev/null +++ b/src/HexManiac.Core/Models/Runs/EggMoveRun.cs @@ -0,0 +1,219 @@ +using HavenSoft.HexManiac.Core.ViewModels.DataFormats; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace HavenSoft.HexManiac.Core.Models.Runs { + public class EggMoveRun : IFormattedRun { + public const int MagicNumber = 0x4E20; // anything above this number is a pokemon, anything below it is a move + public const int EndStream = 0xFFFF; + public const string PokemonNameTable = "pokenames"; + public const string MoveNamesTable = "movenames"; + public const string GroupStart = "["; + public const string GroupEnd = "]"; + private readonly IDataModel model; + + public int Start { get; } + public int Length { get; } + public IReadOnlyList PointerSources { get; private set; } + public string FormatString => AsciiRun.StreamDelimeter + "egg" + AsciiRun.StreamDelimeter; + + public EggMoveRun(IDataModel dataModel, int dataIndex) { + model = dataModel; + Start = dataIndex; + Length = 0; + for (int i = Start; i < model.Count; i += 2) { + if (model[i] == 0xFF && model[i + 1] == 0xFF) { + Length = i - Start + 2; + break; + } + } + } + + public static bool TrySearch(IDataModel data, ModelDelta token, out EggMoveRun eggmoves) { + eggmoves = default; + var pokenames = data.GetNextRun(data.GetAddressFromAnchor(token, -1, PokemonNameTable)) as ArrayRun; + var movenames = data.GetNextRun(data.GetAddressFromAnchor(token, -1, MoveNamesTable)) as ArrayRun; + if (pokenames == null || movenames == null) return false; + + for (var run = data.GetNextRun(0); run.Start < int.MaxValue; run = data.GetNextRun(run.Start + run.Length)) { + if (run is ArrayRun || run is PCSRun || run.PointerSources == null) continue; + + // verify expected pointers to this + if (run.PointerSources.Count != 2) continue; + + // verify limiter + var length = data.ReadMultiByteValue(run.PointerSources[1] - 4, 4); + + // we just read the 'length' from basically a random byte... verify that it could make sense as a length + if (length < 0) continue; + if (run.Start + length * 2 + 3 < 0) continue; + if (run.Start + length * 2 + 3 > data.Count) continue; + var endValue = data.ReadMultiByteValue(run.Start + length * 2 + 2, 2); + if (endValue != EndStream) continue; + + // verify content + bool possibleMatch = true; + for (int i = 0; i < length - 2; i++) { + var value = data.ReadMultiByteValue(run.Start + i * 2, 2); + if (value == EndStream) break; // early exit, the data was edited, but that's ok. Everything still matches up. + if (value >= MagicNumber) { + value -= MagicNumber; + if (value < pokenames.ElementCount) continue; + } + if (value < movenames.ElementCount) continue; + possibleMatch = false; + break; + } + if (!possibleMatch) continue; + + // content is correct, length is correct: this is it! + eggmoves = new EggMoveRun(data, run.Start); + return true; + } + + return false; + } + + private IReadOnlyList cachedPokenames, cachedMovenames; + private int lastFormatRequest = int.MinValue; + public IDataFormat CreateDataFormat(IDataModel data, int dataIndex) { + Debug.Assert(data == model); + if (dataIndex != lastFormatRequest + 1) { + cachedPokenames = ArrayRunEnumSegment.GetOptions(model, PokemonNameTable) ?? new List(); + cachedMovenames = ArrayRunEnumSegment.GetOptions(model, MoveNamesTable) ?? new List(); + } + lastFormatRequest = dataIndex; + + var position = dataIndex - Start; + var groupStart = position % 2 == 1 ? position - 1 : position; + position -= groupStart; + var value = data.ReadMultiByteValue(Start + groupStart, 2); + if (value >= MagicNumber) { + value -= MagicNumber; + string content = cachedPokenames.Count > value ? cachedPokenames[value] : value.ToString(); + if (value == EndStream - MagicNumber) content = string.Empty; + if (content.StartsWith("\"")) content = content.Substring(1); + if (content.EndsWith("\"")) content = content.Substring(0, content.Length - 1); + return new EggSection(groupStart + Start, position, $"{GroupStart}{content}{GroupEnd}"); + } else { + string content = cachedMovenames.Count > value ? cachedMovenames[value] : value.ToString(); + return new EggItem(groupStart + Start, position, content); + } + } + + public IFormattedRun MergeAnchor(IReadOnlyList sources) { + var newSources = new HashSet(); + if (sources != null) newSources.AddRange(sources); + if (PointerSources != null) newSources.AddRange(PointerSources); + return new EggMoveRun(model, Start) { PointerSources = newSources.ToList() }; + } + + public IFormattedRun RemoveSource(int source) { + var sources = PointerSources.ToList(); + sources.Remove(source); + return new EggMoveRun(model, Start) { PointerSources = sources }; + } + + public int GetPokemonNumber(string input) { + if (input.StartsWith(GroupStart)) input = input.Substring(1, input.Length - 2); + var names = cachedPokenames.Select(name => name.Trim('"').ToLower()).ToList(); + return GetNumber(input.ToLower(), names); + } + + public int GetMoveNumber(string input) { + input = input.Trim('"').ToLower(); + var names = cachedMovenames.Select(name => name.Trim('"').ToLower()).ToList(); + return GetNumber(input, names); + } + + public string SerializeForTool() { + var builder = new StringBuilder(); + for (int i = 0; i < Length - 2; i += 2) { + var address = Start + i; + var value = model.ReadMultiByteValue(address, 2); + if (value >= MagicNumber) { + value -= MagicNumber; + builder.Append($"{GroupStart}{cachedPokenames[value].Trim('"')}{GroupEnd}"); + } else { + builder.Append(cachedMovenames[value].Trim('"')); + } + if (i < Length - 4) builder.AppendLine(); + } + return builder.ToString(); + } + + public int DeserializeFromTool(string content, ModelDelta token) { + var data = new List(); + var pokemonNames = cachedPokenames.Select(name => $"{GroupStart}{name.Trim('"').ToLower()}{GroupEnd}").ToList(); + var moveNames = cachedMovenames.Select(name => name.Trim('"').ToLower()).ToList(); + var lines = content.ToLower().Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) { + var index = pokemonNames.IndexOf(line); + if (index != -1) { data.Add(index + MagicNumber); continue; } + index = moveNames.IndexOf(line); + if (index != -1) { data.Add(index); continue; } + + // didn't find an exact match... look for a partial pokemon match + var matchFound = false; + for (int i = 0; i < pokemonNames.Count; i++) { + if (pokemonNames[i].Contains(line)) { data.Add(i + MagicNumber); matchFound = true; break; } + } + if (matchFound) continue; + + // look for a partial move match + for (int i = 0; i < moveNames.Count; i++) { + if (moveNames[i].Contains(line)) { data.Add(i); break; } + } + } + var run = model.RelocateForExpansion(token, this, data.Count * 2 + 2); + for (int i = 0; i < data.Count; i++) model.WriteMultiByteValue(run.Start + i * 2, 2, token, data[i]); + model.WriteMultiByteValue(run.Start + data.Count * 2, 2, token, EndStream); // write the new end token + for (int i = data.Count + 2; i < Length / 2; i++) model.WriteMultiByteValue(run.Start + i * 2, 2, token, EndStream); // fill any remaining old space with FF + return run.Start; + } + + public IEnumerable GetAutoCompleteOptions() { + var pokenames = cachedPokenames.Select(name => $"{GroupStart}{name}{GroupEnd}"); + var movenames = cachedMovenames.Select(name => name + " "); + return pokenames.Concat(movenames); + } + + private static int GetNumber(string input, IList names) { + var matchIndex = names.IndexOf(input); + if (matchIndex != -1) return matchIndex; + var match = names.FirstOrDefault(name => name.Contains(input)); + if (match == null) return -1; + return names.IndexOf(match); + } + + public void AppendTo(StringBuilder text, int start, int length) { + while (length > 0 && start < Start + Length) { + var value = model.ReadMultiByteValue(start, 2); + if (value == EndStream) { + text.Append($"{GroupStart}{GroupEnd}"); + } else if (value >= MagicNumber) { + value -= MagicNumber; + if (value >= cachedPokenames.Count) text.Append($"{GroupStart}{value}{GroupEnd}"); + else text.Append($"{GroupStart}{cachedPokenames[value].Trim('"')}{GroupEnd}"); + } else { + if (value >= cachedMovenames.Count) text.Append($"{value}"); + else text.Append($"{cachedMovenames[value]}"); + } + start += 2; + length -= 2; + if (length > 0 && start < Start + Length) text.Append(" "); + } + } + + public bool UpdateLimiter(ModelDelta token) { + if (PointerSources.Count != 2) return false; + var address = PointerSources.Last() - 4; + var limiter = Length / 2 - 2; + model.WriteMultiByteValue(address, 4, token, limiter); + return true; + } + } +} diff --git a/src/HexManiac.Core/SystemExtensions.cs b/src/HexManiac.Core/SystemExtensions.cs index 12eced9b..a456cd41 100644 --- a/src/HexManiac.Core/SystemExtensions.cs +++ b/src/HexManiac.Core/SystemExtensions.cs @@ -21,5 +21,8 @@ namespace HavenSoft.HexManiac.Core { return true; } + public static void AddRange(this HashSet set, IEnumerable items) { + foreach (var item in items) set.Add(item); + } } } diff --git a/src/HexManiac.Core/ViewModels/ConvertCellToText.cs b/src/HexManiac.Core/ViewModels/ConvertCellToText.cs index 2ac6e205..e69c0567 100644 --- a/src/HexManiac.Core/ViewModels/ConvertCellToText.cs +++ b/src/HexManiac.Core/ViewModels/ConvertCellToText.cs @@ -46,5 +46,9 @@ namespace HavenSoft.HexManiac.Core.ViewModels { public void Visit(Integer integer, byte data) => Result = integer.Value.ToString(); public void Visit(IntegerEnum integerEnum, byte data) => Result = integerEnum.Value; + + public void Visit(EggSection section, byte data) => Result = section.SectionName; + + public void Visit(EggItem item, byte data) => Result = item.ItemName; } } diff --git a/src/HexManiac.Core/ViewModels/DataFormats.cs b/src/HexManiac.Core/ViewModels/DataFormats.cs index 7dc38456..7cb9d0d6 100644 --- a/src/HexManiac.Core/ViewModels/DataFormats.cs +++ b/src/HexManiac.Core/ViewModels/DataFormats.cs @@ -11,6 +11,11 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { void Visit(IDataFormatVisitor visitor, byte data); } + public interface IDataFormatInstance : IDataFormat { + int Source { get; } // the beginning of the run/group that this instance belongs to + int Position { get; } // the index in the run/group that this instance belongs to + } + public interface IDataFormatVisitor { void Visit(Undefined dataFormat, byte data); void Visit(None dataFormat, byte data); @@ -23,6 +28,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { void Visit(Ascii ascii, byte data); void Visit(Integer integer, byte data); void Visit(IntegerEnum integer, byte data); + void Visit(EggSection section, byte data); + void Visit(EggItem item, byte data); } /// @@ -82,7 +89,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { } } - public class Pointer : IDataFormat { + public class Pointer : IDataFormatInstance { public const int NULL = -0x08000000; public int Source { get; } // 6 hex digits public int Position { get; } // 0 through 3 @@ -127,7 +134,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); } - public class PCS : IDataFormat { + public class PCS : IDataFormatInstance { public int Source { get; } public int Position { get; } public string FullString { get; } @@ -143,7 +150,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); } - public class EscapedPCS : IDataFormat { + public class EscapedPCS : IDataFormatInstance { public int Source { get; } public int Position { get; } public string FullString { get; } @@ -159,7 +166,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); } - public class ErrorPCS : IDataFormat { + public class ErrorPCS : IDataFormatInstance { public int Source { get; } public int Position { get; } public string FullString { get; } @@ -175,7 +182,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); } - public class Ascii : IDataFormat { + public class Ascii : IDataFormatInstance { public int Source { get; } public int Position { get; } public char ThisCharacter { get; } @@ -190,7 +197,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); } - public class Integer : IDataFormat { + public class Integer : IDataFormatInstance { public int Source { get; } public int Position { get; } public int Value { get; } @@ -227,4 +234,44 @@ namespace HavenSoft.HexManiac.Core.ViewModels.DataFormats { public override void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); } + + public class EggSection : IDataFormatInstance { + public int Source { get; } + public int Position { get; } + public int Length => 2; + public string SectionName { get; } + + public EggSection(int source, int position, string name) => (Source, Position, SectionName) = (source, position, name); + + public bool Equals(IDataFormat other) { + if (other is EggSection that) { + return that.SectionName == SectionName && + that.Source == Source && + that.Length == Length; + } + return false; + } + + public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); + } + + public class EggItem : IDataFormatInstance { + public int Source { get; } + public int Position { get; } + public int Length { get; } + public string ItemName { get; } + + public EggItem(int source, int position, string name) => (Source, Position, ItemName) = (source, position, name); + + public bool Equals(IDataFormat other) { + if (other is EggItem that) { + return that.ItemName == ItemName && + that.Source == Source && + that.Length == Length; + } + return false; + } + + public void Visit(IDataFormatVisitor visitor, byte data) => visitor.Visit(this, data); + } } diff --git a/src/HexManiac.Core/ViewModels/GotoControlViewModel.cs b/src/HexManiac.Core/ViewModels/GotoControlViewModel.cs index 5c3bd8ba..1e6640be 100644 --- a/src/HexManiac.Core/ViewModels/GotoControlViewModel.cs +++ b/src/HexManiac.Core/ViewModels/GotoControlViewModel.cs @@ -86,7 +86,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels { CanExecute = arg => viewPort?.Goto != null, Execute = arg => { var text = Text; - if (CompletionIndex != -1) text = AutoCompleteOptions[CompletionIndex].CompletionText; + var index = completionIndex.LimitToRange(-1, AutoCompleteOptions.Count - 1); + if (index != -1) text = AutoCompleteOptions[index].CompletionText; if (arg is string) text = (string)arg; viewPort?.Goto?.Execute(text); ControlVisible = false; diff --git a/src/HexManiac.Core/ViewModels/Selection.cs b/src/HexManiac.Core/ViewModels/Selection.cs index e01537b0..726503f1 100644 --- a/src/HexManiac.Core/ViewModels/Selection.cs +++ b/src/HexManiac.Core/ViewModels/Selection.cs @@ -42,7 +42,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels { if (selectionStart.Equals(value)) return; if (!Scroll.ScrollToPoint(ref value)) { - PreviewSelectionStartChanged?.Invoke(this, rawSelectionStart); + PreviewSelectionStartChanged?.Invoke(this, getSpan(rawSelectionStart).start); } rawSelectionStart = value; diff --git a/src/HexManiac.Core/ViewModels/Tools/PCSTool.cs b/src/HexManiac.Core/ViewModels/Tools/PCSTool.cs index 1eedfc9a..12c0b19e 100644 --- a/src/HexManiac.Core/ViewModels/Tools/PCSTool.cs +++ b/src/HexManiac.Core/ViewModels/Tools/PCSTool.cs @@ -66,6 +66,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Tools { if (run.Start > address) return; // wrong run, don't adjust if (run is PCSRun pcsRun) UpdateRun(pcsRun); if (run is ArrayRun arrayRun) UpdateRun(arrayRun); + if (run is EggMoveRun eggRun) UpdateRun(eggRun); } } } @@ -77,7 +78,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Tools { if (ignoreExternalUpdates) return; var run = model.GetNextRun(value); if (TryUpdate(ref address, value)) { - if ((run is PCSRun || run is ArrayRun) && run.Start <= value) { + if ((run is PCSRun || run is ArrayRun || run is EggMoveRun) && run.Start <= value) { runner.Schedule(DataForCurrentRunChanged); Enabled = true; ShowMessage = false; @@ -174,6 +175,13 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Tools { } } return; + } else if (run is EggMoveRun egg) { + var newContent = egg.SerializeForTool(); + ignoreSelectionUpdates = true; + using (new StubDisposable { Dispose = () => ignoreSelectionUpdates = false }) { + TryUpdate(ref content, newContent, nameof(Content)); + } + return; } throw new NotImplementedException(); @@ -186,11 +194,15 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Tools { return newContent; } + /// + /// If a selection update is requested due to a change in the Content, ignore it. + /// Otherwise, the selection update could send us back to the begining of the run. + /// private bool ignoreSelectionUpdates; private void UpdateSelectionFromTool() { if (ignoreSelectionUpdates) return; var run = model.GetNextRun(Address); - if (!(run is ArrayRun) && !(run is PCSRun)) return; + if (!(run is ArrayRun) && !(run is PCSRun) && !(run is EggMoveRun)) return; // for arrays, the address must be at the start of a string segment within the first element of the array if (run is ArrayRun arrayRun) { @@ -216,6 +228,15 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Tools { var afterLines = content.Substring(0, contentIndex + selectionLength).Split(new[] { Environment.NewLine }, StringSplitOptions.None); var selectionEnd = textStart + (afterLines.Length - 1) * array.ElementLength + afterLines[afterLines.Length - 1].Length; selectionLength = selectionEnd - selectionStart; + } else if (run is EggMoveRun egg) { + var beforeSelection = content.Substring(0, selectionStart); + var beforeLineCount = (beforeSelection.Split(new[] { Environment.NewLine }, StringSplitOptions.None).Length - 1).LimitToRange(0, int.MaxValue); + var withSelection = content.Substring(0, selectionStart + selectionLength); + var withSelectionLineCount = (withSelection.Split(new[] { Environment.NewLine }, StringSplitOptions.None).Length - 1).LimitToRange(0, int.MaxValue); + + selectionStart = egg.Start + beforeLineCount * 2; + var selectionEnd = egg.Start + withSelectionLineCount * 2; + selectionLength = selectionEnd - selectionStart; } selection.SelectionStart = selection.Scroll.DataIndexToViewPoint(selectionStart); @@ -272,5 +293,21 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Tools { TryUpdate(ref address, run.Start, nameof(Address)); ignoreExternalUpdates = false; } + + private void UpdateRun(EggMoveRun run) { + ignoreExternalUpdates = true; + var newStart = run.DeserializeFromTool(content, history.CurrentChange); + var newRun = new EggMoveRun(model, newStart); + if (newRun.Length != run.Length) { + model.ObserveRunWritten(history.CurrentChange, newRun); + newRun = (EggMoveRun)model.GetNextRun(newRun.Start); + history.CurrentChange.AddRun(newRun); + newRun.UpdateLimiter(history.CurrentChange); + } + + if (run.Start != newRun.Start) ModelDataMoved?.Invoke(this, (run.Start, newRun.Start)); + ModelDataChanged?.Invoke(this, newRun); + ignoreExternalUpdates = false; + } } } diff --git a/src/HexManiac.Core/ViewModels/ViewPort.cs b/src/HexManiac.Core/ViewModels/ViewPort.cs index aa99d180..060c7d58 100644 --- a/src/HexManiac.Core/ViewModels/ViewPort.cs +++ b/src/HexManiac.Core/ViewModels/ViewPort.cs @@ -141,7 +141,10 @@ namespace HavenSoft.HexManiac.Core.ViewModels { ClearEdits(location); } else { var endEdit = " "; - if (underEdit.CurrentText.Count(c => c == '"') % 2 == 1) 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 (underEdit.CurrentText.StartsWith(EggMoveRun.GroupStart) && (originalFormat is EggSection || originalFormat is EggItem)) endEdit = EggMoveRun.GroupEnd; currentView[location.X, location.Y] = new HexElement(element.Value, underEdit.Edit(endEdit)); if (!TryCompleteEdit(location)) ClearEdits(location); } @@ -165,7 +168,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels { var offsets = array.ConvertByteOffsetToArrayOffset(dataIndex); Tools.StringTool.Address = offsets.SegmentStart - offsets.ElementIndex * array.ElementLength; Tools.TableTool.Address = array.Start + array.ElementLength * offsets.ElementIndex; - } else if (run.Start <= dataIndex && run is PCSRun) { + } else if (run.Start <= dataIndex && (run is PCSRun || run is EggMoveRun)) { Tools.StringTool.Address = run.Start; } else { Tools.StringTool.Address = dataIndex; @@ -587,6 +590,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels { } run = Model.GetNextRun(index); + var cellToText = new ConvertCellToText(Model, run.Start); + var cell = currentView[point.X, point.Y]; if (run is PCSRun pcs) { for (int i = index; i < run.Start + run.Length; i++) history.CurrentChange.ChangeData(Model, i, 0xFF); @@ -605,19 +610,16 @@ namespace HavenSoft.HexManiac.Core.ViewModels { RefreshBackingData(); SelectionStart = scroll.DataIndexToViewPoint(index - 1); } else if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.Pointer) { - var cell = currentView[point.X, point.Y]; PrepareForMultiSpaceEdit(point, 4); var destination = ((Pointer)cell.Format).DestinationAsText; destination = destination.Substring(0, destination.Length - 1); currentView[point.X, point.Y] = new HexElement(cell.Value, new UnderEdit(cell.Format, destination, 4)); } else if (array.ElementContent[offsets.SegmentIndex].Type == ElementContentType.Integer) { - var cell = currentView[point.X, point.Y]; - var format = (Integer)cell.Format; - PrepareForMultiSpaceEdit(point, format.Length); - var text = format.Value.ToString(); - if (format is IntegerEnum intEnum) text = intEnum.Value; + PrepareForMultiSpaceEdit(point, ((Integer)cell.Format).Length); + 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.Value, new UnderEdit(format, text, format.Length)); + currentView[point.X, point.Y] = new HexElement(cell.Value, new UnderEdit(cell.Format, text, ((Integer)cell.Format).Length)); } else { throw new NotImplementedException(); } @@ -625,23 +627,32 @@ namespace HavenSoft.HexManiac.Core.ViewModels { return; } + if (run is EggMoveRun eggRun) { + 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.Value, 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 cellToText = new ConvertCellToText(Model, run.Start); var element = currentView[SelectionStart.X, SelectionStart.Y]; element.Format.Visit(cellToText, element.Value); var text = cellToText.Result; var editLength = 1; if (element.Format is Pointer pointer) editLength = 4; - // if (element.Format is Integer integer) editLength = integer.Length; 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; - currentView[p.X, p.Y] = new HexElement(currentView[p.X, p.Y].Value, new UnderEdit(currentView[p.X, p.Y].Format, editString, editLength)); + var format = new UnderEdit(currentView[p.X, p.Y].Format, editString, editLength); + currentView[p.X, p.Y] = new HexElement(currentView[p.X, p.Y].Value, format); } } else { SelectionStart = scroll.DataIndexToViewPoint(index); @@ -697,38 +708,70 @@ namespace HavenSoft.HexManiac.Core.ViewModels { var textResults = Search(searchBytes).ToList(); Model.ConsiderResultsAsTextRuns(history.CurrentChange, textResults); foreach (var result in textResults) { - // if the result is in an array, we care about things that use that array if (Model.GetNextRun(result) is ArrayRun parentArray && parentArray.LengthFromAnchor == string.Empty) { - var offsets = parentArray.ConvertByteOffsetToArrayOffset(result); - var parentArrayName = Model.GetAnchorFromAddress(-1, parentArray.Start); - if (offsets.SegmentIndex == 0 && parentArray.ElementContent[offsets.SegmentIndex].Type == ElementContentType.PCS) { - foreach (var child in Model.Arrays) { - // option 1: another table has a row named after this element - if (child.LengthFromAnchor == parentArrayName) { - var address = child.Start + child.ElementLength * offsets.ElementIndex; - yield return (address, address + child.ElementLength - 1); - } + foreach (var dataResult in FindMatchingDataResultsFromArrayElement(parentArray, result)) yield return dataResult; + } - // 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; + yield return (result, result + pcsBytes.Count - 1); + } + } + + /// + /// 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.Arrays) { + // option 1: another table has a row named after this element + if (child.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) { + var groupStart = 0; + if (parentArrayName == EggMoveRun.PokemonNameTable) groupStart = EggMoveRun.MagicNumber; + if (parentArrayName == EggMoveRun.PokemonNameTable || parentArrayName == EggMoveRun.MoveNamesTable) { + for (int i = 0; i < eggRun.Length - 2; i += 2) { + if (Model.ReadMultiByteValue(eggRun.Start + i, 2) == offsets.ElementIndex + groupStart) { + yield return (eggRun.Start + i, eggRun.Start + i + 1); } } } } - - yield return (result, result + pcsBytes.Count - 1); } } @@ -868,6 +911,12 @@ namespace HavenSoft.HexManiac.Core.ViewModels { } Tools.SelectedIndex = Enumerable.Range(0, Tools.Count).First(i => Tools[i] is PCSTool); } + if (format is EggSection || format is EggItem) { + var byteOffset = scroll.ViewPointToDataIndex(new Point(x, y)); + var currentRun = Model.GetNextRun(byteOffset); + Tools.StringTool.Address = currentRun.Start; + Tools.SelectedIndex = Enumerable.Range(0, Tools.Count).First(i => Tools[i] is PCSTool); + } } public void ExpandSelection(int x, int y) { @@ -950,6 +999,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels { } private IReadOnlyList GetAutocompleteOptions(IDataFormat originalFormat, string newText, int selectedIndex = -1) { + if (originalFormat is Anchor anchor) originalFormat = anchor.OriginalFormat; if (newText.StartsWith(PointerStart.ToString())) { return Model.GetNewPointerAutocompleteOptions(newText, selectedIndex); } else if (newText.StartsWith(GotoMarker.ToString())) { @@ -959,6 +1009,10 @@ namespace HavenSoft.HexManiac.Core.ViewModels { var segment = (ArrayRunEnumSegment)array.ElementContent[array.ConvertByteOffsetToArrayOffset(intEnum.Source).SegmentIndex]; var options = segment.GetOptions(Model).Select(option => option + " "); // autocomplete needs to complete after selection, so add a space return AutoCompleteSelectionItem.Generate(options.Where(option => option.MatchesPartial(newText)), selectedIndex); + } else if (originalFormat is EggSection || originalFormat is EggItem) { + var eggRun = (EggMoveRun)Model.GetNextRun(((IDataFormatInstance)originalFormat).Source); + var allOptions = eggRun.GetAutoCompleteOptions(); + return AutoCompleteSelectionItem.Generate(allOptions.Where(option => option.MatchesPartial(newText)), selectedIndex); } else { throw new NotImplementedException(); } @@ -1035,12 +1089,20 @@ namespace HavenSoft.HexManiac.Core.ViewModels { var run = Model.GetNextRun(index); if (run.Start > index) return (p, p); - if (run is PointerRun) return (scroll.DataIndexToViewPoint(run.Start), scroll.DataIndexToViewPoint(run.Start + run.Length - 1)); + (Point, Point) pair(int start, int end) => (scroll.DataIndexToViewPoint(start), scroll.DataIndexToViewPoint(end)); + + if (run is PointerRun) return pair(run.Start, run.Start + run.Length - 1); + if (run is EggMoveRun) { + var even = (index - run.Start) % 2 == 0; + if (even) return pair(index, index + 1); + return pair(index - 1, index); + } if (!(run is ArrayRun array)) return (p, p); var offset = array.ConvertByteOffsetToArrayOffset(index); - if (array.ElementContent[offset.SegmentIndex].Type == ElementContentType.Pointer || array.ElementContent[offset.SegmentIndex].Type == ElementContentType.Integer) { - return (scroll.DataIndexToViewPoint(offset.SegmentStart), scroll.DataIndexToViewPoint(offset.SegmentStart + array.ElementContent[offset.SegmentIndex].Length - 1)); + var type = array.ElementContent[offset.SegmentIndex].Type; + if (type == ElementContentType.Pointer || type == ElementContentType.Integer) { + return pair(offset.SegmentStart, offset.SegmentStart + array.ElementContent[offset.SegmentIndex].Length - 1); } return (p, p); @@ -1101,6 +1163,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels { RefreshBackingData(); } 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 ArrayRun) Tools.Schedule(Tools.TableTool.DataForCurrentRunChanged); if (run is ArrayRun || run is PCSRun) Tools.Schedule(Tools.StringTool.DataForCurrentRunChanged); diff --git a/src/HexManiac.Core/ViewModels/Visitors/CompleteEditOperation.cs b/src/HexManiac.Core/ViewModels/Visitors/CompleteEditOperation.cs index 3bb15014..abb4e250 100644 --- a/src/HexManiac.Core/ViewModels/Visitors/CompleteEditOperation.cs +++ b/src/HexManiac.Core/ViewModels/Visitors/CompleteEditOperation.cs @@ -19,8 +19,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { public int NewDataIndex { get; private set; } // for completed edits, where should the selection move to? public bool DataMoved { get; private set; } public string MessageText { get; private set; } // is there a message to display to the user? For example, when data gets moved. - public string ErrorText { get; private set; } // is there an error to display to the user? For example, invalide pointer - public HexElement NewCell { get; private set; } // if result is true and this is not null, asign this one value back to the one cell + public string ErrorText { get; private set; } // is there an error to display to the user? For example, invalid pointer + public HexElement NewCell { get; private set; } // if result is true and this is not null, assign this one value back to the one cell // and refresh the one cell (along with any other UnderEdit cells) // if result is true and this _is_ null, then the entire screen needs to be refreshed. @@ -109,6 +109,10 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { } } + public void Visit(EggSection section, byte data) => CompleteEggEdit(); + + public void Visit(EggItem item, byte data) => CompleteEggEdit(); + private void CompleteIntegerEdit(Integer integer) { if (!int.TryParse(CurrentText, out var result)) { ErrorText = $"Could not parse {CurrentText} as a number"; @@ -279,5 +283,73 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { CurrentChange.ChangeData(Model, memoryLocation, byteValue); NewDataIndex = memoryLocation + 1; } + + private void CompleteEggEdit() { + var endChar = CurrentText[CurrentText.Length - 1]; + if (!$"{EggMoveRun.GroupEnd} {StringDelimeter}".Contains(endChar)) return; + if (CurrentText.Count(c => c == StringDelimeter) % 2 != 0) return; + + NewDataIndex = memoryLocation + 2; + Result = true; + var run = (EggMoveRun)Model.GetNextRun(memoryLocation); + + if (CurrentText == EggMoveRun.GroupStart + EggMoveRun.GroupEnd) { + Model.WriteMultiByteValue(memoryLocation, 2, CurrentChange, 0xFFFF); + // clear all data after this and shorten the run + for (int i = memoryLocation + 2; i < run.Start + run.Length; i += 2) { + Model.WriteMultiByteValue(i, 2, CurrentChange, 0xFFFF); + } + var newRun = new EggMoveRun(Model, run.Start); + Model.ObserveRunWritten(CurrentChange, newRun); + newRun = (EggMoveRun)Model.GetNextRun(newRun.Start); + newRun.UpdateLimiter(CurrentChange); + } else if (CurrentText.EndsWith(EggMoveRun.GroupEnd)) { + var value = run.GetPokemonNumber(CurrentText); + if (value == -1) { + ErrorText = $"Could not parse {CurrentText} as a pokemon name"; + NewDataIndex -= 2; + } else { + WriteNormalEggEdit(run, value + EggMoveRun.MagicNumber); + } + } else { + var text = CurrentText.Trim(); + var value = run.GetMoveNumber(text); + if (value == -1) { + // wasn't a move... try again as a pokemon even though they didn't use the [] + value = run.GetPokemonNumber(text); + if (value == -1) { + ErrorText = $"Could not parse {text} as a move name or pokemon name"; + NewDataIndex -= 2; + } else { + WriteNormalEggEdit(run, value + EggMoveRun.MagicNumber); + } + } else { + WriteNormalEggEdit(run, value); + } + } + } + + /// + /// Before we write this change to the model, see if we need to extend the egg run to make it fit. + /// + private void WriteNormalEggEdit(EggMoveRun run, int value) { + int memoryLocation = this.memoryLocation; + var initialItemValue = Model.ReadMultiByteValue(memoryLocation, 2); + Model.WriteMultiByteValue(memoryLocation, 2, CurrentChange, value); + if (initialItemValue == 0xFFFF) { + var newRun = Model.RelocateForExpansion(CurrentChange, run, run.Length + 2); + if (newRun.Start != run.Start) { + MessageText = $"Egg Moves were automatically moved to {newRun.Start.ToString("X6")}. Pointers were updated."; + memoryLocation += newRun.Start - run.Start; + NewDataIndex = memoryLocation + 2; + DataMoved = true; + } + Model.WriteMultiByteValue(memoryLocation + 2, 2, CurrentChange, 0xFFFF); + var eggRun = new EggMoveRun(Model, newRun.Start); + Model.ObserveRunWritten(CurrentChange, eggRun); + eggRun = (EggMoveRun)Model.GetNextRun(eggRun.Start); + eggRun.UpdateLimiter(CurrentChange); + } + } } } diff --git a/src/HexManiac.Core/ViewModels/Visitors/ContextItemFactory.cs b/src/HexManiac.Core/ViewModels/Visitors/ContextItemFactory.cs index 94676de2..26ea4f8b 100644 --- a/src/HexManiac.Core/ViewModels/Visitors/ContextItemFactory.cs +++ b/src/HexManiac.Core/ViewModels/Visitors/ContextItemFactory.cs @@ -117,6 +117,16 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { Results.AddRange(GetTableChildren(arrayRun)); } + public void Visit(EggSection section, byte data) { + var point = ViewPort.SelectionStart; + Results.Add(new ContextItem("Open In Text Tool", arg => ViewPort.FollowLink(point.X, point.Y)) { ShortcutText = "Ctrl+Click" }); + } + + public void Visit(EggItem item, byte data) { + var point = ViewPort.SelectionStart; + Results.Add(new ContextItem("Open In Text Tool", arg => ViewPort.FollowLink(point.X, point.Y)) { ShortcutText = "Ctrl+Click" }); + } + private IEnumerable GetTableChildren(ArrayRun array) { if (ViewPort.Tools.TableTool.Append.CanExecute(null)) { yield return new ContextItem("Extend Table", ViewPort.Tools.TableTool.Append.Execute); diff --git a/src/HexManiac.Core/ViewModels/Visitors/ContinueCellEdit.cs b/src/HexManiac.Core/ViewModels/Visitors/ContinueCellEdit.cs index 461d3e6a..ee9acf5b 100644 --- a/src/HexManiac.Core/ViewModels/Visitors/ContinueCellEdit.cs +++ b/src/HexManiac.Core/ViewModels/Visitors/ContinueCellEdit.cs @@ -1,4 +1,5 @@ using HavenSoft.HexManiac.Core.Models; +using HavenSoft.HexManiac.Core.Models.Runs; using HavenSoft.HexManiac.Core.ViewModels.DataFormats; using System; using System.Linq; @@ -71,5 +72,17 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { ".~".Contains(Input) || char.IsWhiteSpace(Input); } + + public void Visit(EggSection section, byte data) => VisitEgg(); + public void Visit(EggItem item, byte data) => VisitEgg(); + public void VisitEgg() { + var specialCharacters = ". '-\\"; // mr. mime, farfetch'd, double-edge, nidoran + if (UnderEdit.CurrentText.StartsWith(EggMoveRun.GroupStart)) specialCharacters += ']'; + if (UnderEdit.CurrentText.StartsWith(StringDelimeter.ToString())) specialCharacters += StringDelimeter; + Result = + char.IsLetterOrDigit(Input) || + specialCharacters.Contains(Input) || + char.IsWhiteSpace(Input); + } } } diff --git a/src/HexManiac.Core/ViewModels/Visitors/DataClear.cs b/src/HexManiac.Core/ViewModels/Visitors/DataClear.cs index 5d7c5b4b..409e3581 100644 --- a/src/HexManiac.Core/ViewModels/Visitors/DataClear.cs +++ b/src/HexManiac.Core/ViewModels/Visitors/DataClear.cs @@ -1,4 +1,5 @@ using HavenSoft.HexManiac.Core.Models; +using HavenSoft.HexManiac.Core.Models.Runs; using HavenSoft.HexManiac.Core.ViewModels.DataFormats; using System; @@ -40,8 +41,12 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { public void Visit(Ascii ascii, byte data) => currentChange.ChangeData(buffer, index, 0xFF); - public void Visit(Integer integer, byte data) => buffer.WriteValue(currentChange, index, 0); + public void Visit(Integer integer, byte data) => buffer.WriteMultiByteValue(index, integer.Length, currentChange, 0); - public void Visit(IntegerEnum integerEnum, byte data) => buffer.WriteValue(currentChange, index, 0); + public void Visit(IntegerEnum integerEnum, byte data) => buffer.WriteMultiByteValue(index, integerEnum.Length, currentChange, 0); + + public void Visit(EggSection section, byte data) => buffer.WriteMultiByteValue(index, 2, currentChange, EggMoveRun.MagicNumber); + + public void Visit(EggItem item, byte data) => buffer.WriteMultiByteValue(index, 2, currentChange, 0x0000); } } diff --git a/src/HexManiac.Core/ViewModels/Visitors/StartCellEdit.cs b/src/HexManiac.Core/ViewModels/Visitors/StartCellEdit.cs index babd636a..1cf29833 100644 --- a/src/HexManiac.Core/ViewModels/Visitors/StartCellEdit.cs +++ b/src/HexManiac.Core/ViewModels/Visitors/StartCellEdit.cs @@ -49,7 +49,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { // just continue the existing edit. public void Visit(UnderEdit underEdit, byte data) { // handle special cases of "anywhere" formats first - if (underEdit.CurrentText[0] == ViewPort.GotoMarker) { + if (underEdit.CurrentText.StartsWith(ViewPort.GotoMarker.ToString())) { Result = char.IsLetterOrDigit(Input) || Input == ArrayAnchorSeparator || char.IsWhiteSpace(Input); return; } else if (underEdit.CurrentText.StartsWith(AnchorStart.ToString())) { @@ -67,10 +67,7 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { } public void Visit(Pointer pointer, byte data) { - if (Input != PointerStart && !char.IsLetterOrDigit(Input)) { - Result = false; - return; - } + if (Input != PointerStart && !char.IsLetterOrDigit(Input)) return; var editText = Input.ToString(); // if the user tries to edit the pointer but forgets the opening bracket, add it for them. @@ -89,10 +86,8 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { public void Visit(PCS pcs, byte data) { if (Model.GetNextRun(MemoryLocation) is ArrayRun array) { var offsets = array.ConvertByteOffsetToArrayOffset(MemoryLocation); - if (offsets.SegmentStart == MemoryLocation && Input == ' ') { - Result = false; // don't let it start with a space unless it's in quotes (for copy/paste) - return; - } + // don't let it start with a space unless it's in quotes (for copy/paste) + if (offsets.SegmentStart == MemoryLocation && Input == ' ') return; } Result = Input == StringDelimeter || PCSString.PCS.Any(str => str != null && str.StartsWith(Input.ToString())); @@ -109,20 +104,14 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { public void Visit(Ascii ascii, byte data) => Result = true; public void Visit(Integer intFormat, byte data) { - if (!intFormat.CanStartWithCharacter(Input)) { - Result = false; - return; - } + if (!intFormat.CanStartWithCharacter(Input)) return; NewFormat = new UnderEdit(intFormat, Input.ToString(), intFormat.Length, null); Result = true; } public void Visit(IntegerEnum integer, byte data) { - if (!integer.CanStartWithCharacter(Input)) { - Result = false; - return; - } + if (!integer.CanStartWithCharacter(Input)) return; var arrayRun = (ArrayRun)Model.GetNextRun(MemoryLocation); var offsets = arrayRun.ConvertByteOffsetToArrayOffset(MemoryLocation); @@ -132,5 +121,17 @@ namespace HavenSoft.HexManiac.Core.ViewModels.Visitors { NewFormat = new UnderEdit(integer, Input.ToString(), integer.Length, autocomplete); Result = true; } + + public void Visit(EggSection section, byte data) => VisitEgg(section); + public void Visit(EggItem item, byte data) => VisitEgg(item); + public void VisitEgg(IDataFormat eggFormat) { + if (!char.IsLetterOrDigit(Input) && !$"{StringDelimeter}[".Contains(Input)) return; + + var stream = (EggMoveRun)Model.GetNextRun(MemoryLocation); + var allOptions = stream.GetAutoCompleteOptions(); + var autocomplete = AutoCompleteSelectionItem.Generate(allOptions.Where(option => option.MatchesPartial(Input.ToString())), -1); + NewFormat = new UnderEdit(eggFormat, Input.ToString(), 2, autocomplete); + Result = true; + } } } diff --git a/src/HexManiac.Tests/AutoSearchTests.cs b/src/HexManiac.Tests/AutoSearchTests.cs index 81003cbd..350fc9ff 100644 --- a/src/HexManiac.Tests/AutoSearchTests.cs +++ b/src/HexManiac.Tests/AutoSearchTests.cs @@ -30,7 +30,7 @@ namespace HavenSoft.HexManiac.Tests { var model = LoadModel(game); var noChange = new NoDataChangeDeltaModel(); - var address = model.GetAddressFromAnchor(noChange, -1, "pokenames"); + var address = model.GetAddressFromAnchor(noChange, -1, EggMoveRun.PokemonNameTable); var run = (ArrayRun)model.GetNextAnchor(address); Assert.Equal(412, run.ElementCount); } @@ -41,7 +41,7 @@ namespace HavenSoft.HexManiac.Tests { var model = LoadModel(game); var noChange = new NoDataChangeDeltaModel(); - var address = model.GetAddressFromAnchor(noChange, -1, "movenames"); + var address = model.GetAddressFromAnchor(noChange, -1, EggMoveRun.MoveNamesTable); var run = (ArrayRun)model.GetNextAnchor(address); Assert.Equal(355, run.ElementCount); } @@ -141,6 +141,22 @@ namespace HavenSoft.HexManiac.Tests { for (int i = 0; i < compareSet.Length; i++) Assert.Equal(compareSet[i], poundStats[i]); } + [SkippableTheory] + [MemberData(nameof(PokemonGames))] + public void EggMoveDataFound(string game) { + var model = LoadModel(game); + var noChange = new NoDataChangeDeltaModel(); + + var address = model.GetAddressFromAnchor(noChange, -1, "eggmoves"); + var run = (EggMoveRun)model.GetNextAnchor(address); + + Assert.Equal(2, run.PointerSources.Count); + var expectedLastElement = model.ReadMultiByteValue(run.PointerSources[1] - 4, 4); + var expectedLength = expectedLastElement + 1; + var actualLength = run.Length / 2 - 1; // remove the closing element. + Assert.InRange(actualLength, 790, expectedLength); + } + /// /// Loading the model can take a while. /// We want to know that loading the model created the correct arrays, diff --git a/src/HexManiac.Tests/EggMoveTests.cs b/src/HexManiac.Tests/EggMoveTests.cs new file mode 100644 index 00000000..47deeaf5 --- /dev/null +++ b/src/HexManiac.Tests/EggMoveTests.cs @@ -0,0 +1,234 @@ +using HavenSoft.HexManiac.Core; +using HavenSoft.HexManiac.Core.Models; +using HavenSoft.HexManiac.Core.Models.Runs; +using HavenSoft.HexManiac.Core.ViewModels; +using HavenSoft.HexManiac.Core.ViewModels.DataFormats; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace HavenSoft.HexManiac.Tests { + public class EggMoveTests { + private readonly byte[] data; + private readonly PokemonModel model; + private readonly ViewPort viewPort; + private readonly List messages = new List(); + + #region Setup + + public EggMoveTests() { + data = Enumerable.Range(0, 0x200).Select(i => (byte)0xFF).ToArray(); + model = new PokemonModel(data); + viewPort = new ViewPort("file.gba", model); + + viewPort.Goto.Execute("000080"); + viewPort.Edit("^pokenames[name\"\"8]8 \"Bob\" \"Steve\" \"Carl\" \"Sam\" \"Bryan\" \"Ryan\" \"Ian\" \"Matt\""); + + viewPort.Goto.Execute("000100"); + viewPort.Edit("^movenames[name\"\"8]8 \"Fire\" \"Water\" \"Earth\" \"Wind\" \"Light\" \"Dark\" \"Normal\" \"Magic\""); + + viewPort.Goto.Execute("000180"); + viewPort.Edit("<000000> Dead Beef 01 00 00 00 <000000>"); // limiter is at 188 for an eggrun at 000 + + viewPort.Goto.Execute("000000"); + + viewPort.OnMessage += (sender, e) => messages.Add(e); + } + + private void CreateSimpleRun() { + var token = new ModelDelta(); + model.WriteMultiByteValue(0, 2, token, EggMoveRun.MagicNumber + 2); // Carl + model.WriteMultiByteValue(2, 2, token, 3); // Wind + + viewPort.Edit("^eggmoves`egg` "); + } + + #endregion + + [Fact] + public void CanCreateEggMoveStream() { + viewPort.Edit("^eggmoves`egg` "); + + Assert.Equal(2, model.GetNextRun(0).Length); + } + + [Fact] + public void CanSeeEggMoveStreamWithCorrectFormat() { + CreateSimpleRun(); + + Assert.Equal(6, model.GetNextRun(0).Length); + var section = (EggSection)viewPort[1, 0].Format; + var item = (EggItem)viewPort[2, 0].Format; + var endSection = (EggSection)viewPort[5, 0].Format; + Assert.Equal("[Carl]", section.SectionName); + Assert.Equal("Wind", item.ItemName); + Assert.Equal("[]", endSection.SectionName); + } + + [Fact] + public void SelectionDoneInPairs() { + CreateSimpleRun(); + + viewPort.SelectionStart = new Point(2, 0); // should select "Wind" + Assert.True(viewPort.IsSelected(new Point(3, 0))); + + viewPort.MoveSelectionStart.Execute(Direction.Right); // should select "[]" + Assert.True(viewPort.IsSelected(new Point(4, 0))); + Assert.True(viewPort.IsSelected(new Point(5, 0))); + } + + [Fact] + public void CanEditEggStreamManually() { + CreateSimpleRun(); + + viewPort.Edit("Dark "); + Assert.Equal(5, model[0]); + + viewPort.Edit("[Bryan]"); + Assert.Equal(EggMoveRun.MagicNumber + 4, model.ReadMultiByteValue(2, 2)); + } + + [Fact] + public void CanCopyPaste() { + CreateSimpleRun(); + var fileSystem = new StubFileSystem(); + + viewPort.SelectionStart = new Point(2, 0); + viewPort.SelectionEnd = new Point(4, 0); + viewPort.Copy.Execute(fileSystem); + + Assert.Equal("Wind []", fileSystem.CopyText.value); + } + + [Fact] + public void RunAutoExtends() { + CreateSimpleRun(); + viewPort.SelectionStart = new Point(4, 0); + viewPort.Edit("[Carl]"); + + Assert.Equal(8, model.GetNextRun(0).Length); + Assert.Equal(2, model.ReadMultiByteValue(0x188, 4)); + } + + [Fact] + public void RunAutoMoves() { + viewPort.Edit("^eggmoves`egg` "); + model.WriteMultiByteValue(2, 2, new ModelDelta(), 0x0206); + viewPort.Edit("[Carl]"); + + Assert.Single(messages); + } + + [Fact] + public void RunAutoShortens() { + CreateSimpleRun(); + viewPort.SelectionStart = new Point(2, 0); + viewPort.Edit("[]"); + + Assert.Equal(4, model.GetNextRun(0).Length); + Assert.Equal(0, model.ReadMultiByteValue(0x188, 4)); + } + + [Fact] + public void CanViewInTextTool() { + CreateSimpleRun(); + viewPort.SelectionStart = new Point(2, 2); // select off it + viewPort.SelectionStart = new Point(2, 0); // and then back on + viewPort.Tools.StringToolCommand.Execute(); + + Assert.Equal(@"[Carl] +Wind", viewPort.Tools.StringTool.Content); + + viewPort.Tools.StringTool.Content = @"[Carl] +Earth +Light +[Ryan] +Fire +Water"; + + Assert.Equal(14, model.GetNextRun(0).Length); + Assert.Equal(5, model.ReadMultiByteValue(0x188, 4)); + } + + [Fact] + public void FollowLinkOpensTextTool() { + CreateSimpleRun(); + viewPort.SelectionStart = new Point(2, 0); + viewPort.FollowLink(2, 0); + + Assert.Equal(0, viewPort.Tools.SelectedIndex); + } + + [Fact] + public void RightClickOptionToOpenTextTool() { + CreateSimpleRun(); + viewPort.SelectionStart = new Point(2, 0); + var items = viewPort.GetContextMenuItems(viewPort.SelectionStart); + Assert.Contains(items, item => item.ShortcutText == "Ctrl+Click"); + } + + [Fact] + public void ToolCursorChangesCauseSelectionChanges() { + CreateSimpleRun(); + viewPort.SelectionStart = new Point(2, 0); + viewPort.Tools.StringToolCommand.Execute(); + viewPort.Tools.StringTool.ContentIndex = 5; + + Assert.True(viewPort.IsSelected(new Point(0, 0))); + Assert.True(viewPort.IsSelected(new Point(1, 0))); + } + + [Fact] + public void SearchFindsEggMoveData() { + CreateSimpleRun(); + + var pairs = viewPort.Find("wind"); + Assert.Contains((2, 3), pairs); + + pairs = viewPort.Find("carl"); + Assert.Contains((0, 1), pairs); + } + + [Fact] + public void BackspaceWorks() { + CreateSimpleRun(); + + viewPort.SelectionStart = new Point(2, 0); + viewPort.Edit(ConsoleKey.Backspace); + + var format = (UnderEdit)viewPort[2, 0].Format; + Assert.Equal("Win", format.CurrentText); // Wind, but backspaced + } + + /// + /// Since egg moves are multiple cells long, we want to know that + /// changes get reverted when 'selectionstart' is the rightmost cell + /// + [Fact] + public void SelectLeftThenBackspaceThenDownCompletesEdits() { + CreateSimpleRun(); + + viewPort.SelectionStart = new Point(2, 0); + viewPort.MoveSelectionStart.Execute(Direction.Left); + viewPort.Edit(ConsoleKey.Backspace); + viewPort.MoveSelectionStart.Execute(Direction.Down); + + Assert.IsNotType(viewPort[1, 0].Format); + } + + [Fact] + public void CanClearCellContent() { + CreateSimpleRun(); + + viewPort.SelectionStart = new Point(2, 0); + viewPort.Edit(ConsoleKey.Backspace); // d + viewPort.Edit(ConsoleKey.Backspace); // n + viewPort.Edit(ConsoleKey.Backspace); // i + viewPort.Edit(ConsoleKey.Backspace); // W + + var format = (UnderEdit)viewPort[2, 0].Format; + Assert.Equal(string.Empty, format.CurrentText); + } + } +} diff --git a/src/HexManiac.Tests/HexManiac.Tests.csproj b/src/HexManiac.Tests/HexManiac.Tests.csproj index c8cbb7e8..f56c37dd 100644 --- a/src/HexManiac.Tests/HexManiac.Tests.csproj +++ b/src/HexManiac.Tests/HexManiac.Tests.csproj @@ -71,6 +71,7 @@ + diff --git a/src/HexManiac.WPF/Implementations/FormatDrawer.cs b/src/HexManiac.WPF/Implementations/FormatDrawer.cs index 88842e87..efb3a83a 100644 --- a/src/HexManiac.WPF/Implementations/FormatDrawer.cs +++ b/src/HexManiac.WPF/Implementations/FormatDrawer.cs @@ -1,6 +1,7 @@ using HavenSoft.HexManiac.Core.ViewModels; using HavenSoft.HexManiac.Core.ViewModels.DataFormats; using HavenSoft.HexManiac.WPF.Controls; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -45,14 +46,7 @@ namespace HavenSoft.HexManiac.WPF.Implementations { var content = dataFormat.CurrentText; - var text = new FormattedText( - content, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize, - brush, - 1.0); + var text = CreateText(content, FontSize, brush); var offset = CellTextOffset; var widthOverflow = text.Width - HexContent.CellWidth * dataFormat.EditWidth; @@ -72,18 +66,10 @@ namespace HavenSoft.HexManiac.WPF.Implementations { if (dataFormat.Destination < 0) brush = Brush(nameof(Theme.Error)); Underline(brush, dataFormat.Position == 0, dataFormat.Position == 3); - var typeface = new Typeface("Consolas"); var destination = dataFormat.DestinationAsText; if (destination.Length > 13) destination = destination.Substring(0, 11) + "…>"; var xOffset = 51 - (dataFormat.Position * HexContent.CellWidth) - destination.Length * 4.2; // centering - var text = new FormattedText( - destination, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize, - brush, - 1.0); + var text = CreateText(destination, FontSize, brush); if (dataFormat.Position > Position.X || Position.X - dataFormat.Position > modelWidth - 4) { context.PushClip(rectangleGeometry); @@ -103,15 +89,7 @@ namespace HavenSoft.HexManiac.WPF.Implementations { } public void Visit(PCS pcs, byte data) { - var typeface = new Typeface("Consolas"); - var text = new FormattedText( - pcs.ThisCharacter, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize, - Brush(nameof(Theme.Text1)), - 1.0); + var text = CreateText(pcs.ThisCharacter, FontSize, Brush(nameof(Theme.Text1))); var xOffset = 1 - pcs.ThisCharacter.Length; context.DrawText(text, new Point(CellTextOffset.X + xOffset, CellTextOffset.Y)); @@ -123,33 +101,16 @@ namespace HavenSoft.HexManiac.WPF.Implementations { public void Visit(ErrorPCS pcs, byte data) { var brush = Brush(nameof(Theme.Error)); - var typeface = new Typeface("Consolas"); var content = data.ToString("X2"); - var text = new FormattedText( - content, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize, - brush, - 1.0); + var text = CreateText(content, FontSize, brush); context.DrawText(text, CellTextOffset); } public void Visit(Ascii ascii, byte data) { - var typeface = new Typeface("Consolas"); - var text = new FormattedText( - ascii.ThisCharacter.ToString(), - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize, - Brush(nameof(Theme.Text2)), - 1.0); - + var text = CreateText(ascii.ThisCharacter.ToString(), FontSize, Brush(nameof(Theme.Text2))); context.DrawText(text, CellTextOffset); } @@ -158,15 +119,7 @@ namespace HavenSoft.HexManiac.WPF.Implementations { var stringValue = integer.Value.ToString(); - var typeface = new Typeface("Consolas"); - var text = new FormattedText( - stringValue, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize, - Brush(nameof(Theme.Data1)), - 1.0); + var text = CreateText(stringValue, FontSize, Brush(nameof(Theme.Data1))); var xOffset = CellTextOffset.X; xOffset += HexContent.CellWidth / 2 * (integer.Length - 1); // adjust based on number of cells to use @@ -178,16 +131,7 @@ namespace HavenSoft.HexManiac.WPF.Implementations { if (integerEnum.Position != 0) return; var stringValue = integerEnum.Value; - - var typeface = new Typeface("Consolas"); - var text = new FormattedText( - stringValue, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - FontSize * 3 / 4, - Brush(nameof(Theme.Data2)), - 1.0); + var text = CreateText(stringValue, FontSize * 3 / 4, Brush(nameof(Theme.Data2))); var xOffset = CellTextOffset.X / 2; context.PushClip(new RectangleGeometry(new Rect(0, 0, HexContent.CellWidth * integerEnum.Length, HexContent.CellHeight))); @@ -195,6 +139,32 @@ namespace HavenSoft.HexManiac.WPF.Implementations { context.Pop(); } + public void Visit(EggSection section, byte data) { + if (section.Position != 0) return; + var name = section.SectionName; + + var text = CreateText(name, FontSize * 3 / 4, Brush(nameof(Theme.Stream1))); + var characterWidth = text.Width / name.Length; + var xOffset = HexContent.CellWidth - name.Length * characterWidth / 2; + if (xOffset < 0) xOffset = 0; + context.PushClip(new RectangleGeometry(new Rect(0, 0, HexContent.CellWidth * 2, HexContent.CellHeight))); + context.DrawText(text, new Point(xOffset, CellTextOffset.Y + 2)); + context.Pop(); + } + + public void Visit(EggItem item, byte data) { + if (item.Position != 0) return; + var name = item.ItemName; + + var text = CreateText(name, FontSize * 3 / 4, Brush(nameof(Theme.Stream2))); + var characterWidth = text.Width / name.Length; + var xOffset = HexContent.CellWidth - name.Length * characterWidth / 2; + if (xOffset < 0) xOffset = 0; + context.PushClip(new RectangleGeometry(new Rect(0, 0, HexContent.CellWidth * 2, HexContent.CellHeight))); + context.DrawText(text, new Point(xOffset, CellTextOffset.Y + 2)); + context.Pop(); + } + private void Underline(Brush brush, bool isStart, bool isEnd) { int startPoint = isStart ? 5 : 0; int endPoint = (int)HexContent.CellWidth - (isEnd ? 5 : 0); @@ -227,6 +197,18 @@ namespace HavenSoft.HexManiac.WPF.Implementations { noneVisualCache.AddRange(text); } + private static readonly Typeface consolas = new Typeface("Consolas"); + private static FormattedText CreateText(string text, double size, Brush color) { + return new FormattedText( + text, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + consolas, + size, + color, + 1.0); + } + private static SolidColorBrush Brush(string name) { return (SolidColorBrush)Application.Current.Resources.MergedDictionaries[0][name]; } diff --git a/src/SharedAssemblyInfo.cs b/src/SharedAssemblyInfo.cs index f50d7168..afc894d7 100644 --- a/src/SharedAssemblyInfo.cs +++ b/src/SharedAssemblyInfo.cs @@ -6,5 +6,5 @@ [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("0.2.0.0")] -[assembly: AssemblyFileVersion("0.2.0.0")] +[assembly: AssemblyVersion("0.2.1.0")] +[assembly: AssemblyFileVersion("0.2.1.0")]