diff --git a/Diz.Controllers/Diz.Controllers/src/controllers/ProjectController.cs b/Diz.Controllers/Diz.Controllers/src/controllers/ProjectController.cs index 628d0171..99e79b0b 100644 --- a/Diz.Controllers/Diz.Controllers/src/controllers/ProjectController.cs +++ b/Diz.Controllers/Diz.Controllers/src/controllers/ProjectController.cs @@ -14,6 +14,7 @@ using Diz.Core.serialization.xml_serializer; using Diz.Core.util; using Diz.Cpu._65816; +using Diz.Import; using Diz.Import.bizhawk; using Diz.Import.bsnes.usagemap; using Diz.LogWriter; @@ -204,7 +205,7 @@ public void ImportLabelsCsv(ILabelEditorView labelEditor, bool replaceAll) var errLine = 0; try { - Project.Data.Labels.ImportLabelsFromCsv(importFilename, replaceAll, ref errLine); + Project.Data.Labels.ImportLabelsFromCsv(importFilename, replaceAll, out errLine); labelEditor.RepopulateFromData(); } catch (Exception ex) diff --git a/Diz.Core.Interfaces/LabelInterfaces.cs b/Diz.Core.Interfaces/LabelInterfaces.cs index fe9e1dcd..c8f5d83b 100644 --- a/Diz.Core.Interfaces/LabelInterfaces.cs +++ b/Diz.Core.Interfaces/LabelInterfaces.cs @@ -30,6 +30,7 @@ public interface ILabelProvider void RemoveLabel(int snesAddress); void SetAll(Dictionary newLabels); + void AppendLabels(Dictionary newLabels); } public interface IReadOnlyLabels diff --git a/Diz.Core/model/LabelProvider.cs b/Diz.Core/model/LabelProvider.cs index cd3dd239..fc924b6e 100644 --- a/Diz.Core/model/LabelProvider.cs +++ b/Diz.Core/model/LabelProvider.cs @@ -120,6 +120,13 @@ public void SetAll(Dictionary newLabels) OnLabelChanged?.Invoke(this, EventArgs.Empty); } + + public void AppendLabels(Dictionary newLabels) + { + NormalProvider.AppendLabels(newLabels); + + OnLabelChanged?.Invoke(this, EventArgs.Empty); + } #region "Equality" public bool Equals(LabelsServiceWithTemp other) @@ -276,6 +283,11 @@ public void RemoveLabel(int snesAddress) public void SetAll(Dictionary newLabels) { DeleteAllLabels(); + AppendLabels(newLabels); + } + + public void AppendLabels(Dictionary newLabels) + { foreach (var key in newLabels.Keys) { Labels.Add(key, newLabels[key]); diff --git a/Diz.Core/util/Util.cs b/Diz.Core/util/Util.cs index 9334ced0..e86cf435 100644 --- a/Diz.Core/util/Util.cs +++ b/Diz.Core/util/Util.cs @@ -126,13 +126,13 @@ public static int ParseHexOrBase10String(string data) return int.Parse(data); } + // this function is a little weird and redundant maybe? public static IEnumerable ReadLines(string path) { using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 0x1000, FileOptions.SequentialScan); using var sr = new StreamReader(fs, Encoding.UTF8); - string line; - while ((line = sr.ReadLine()) != null) + while (sr.ReadLine() is { } line) { yield return line; } @@ -467,98 +467,6 @@ public static bool FieldIsEqual(T field, T value, bool compareRefOnly = false public static class ContentUtils { - public static Dictionary ReadLabelsFromCsv(string importFilename, out int errLine) - { - var newValues = new Dictionary(); - var lines = Util.ReadLines(importFilename).ToArray(); - - var validLabelChars = new Regex(@"^([a-zA-Z0-9_\-]*)$"); - - // Coming in from BSNES symbol map if it begins with the header - var fromBSNES = lines.Length > 0 && lines[0].StartsWith("#SNES65816"); - var inSection = string.Empty; - - errLine = 0; - - for (var i = 0; i < lines.Length; i++) - { - var label = new Label(); - - string labelAddress = string.Empty; - - errLine = i + 1; - - if (fromBSNES) - { - // Skip the line if it's empty or a comment - if (lines[i].Trim().Length == 0 || lines[i].StartsWith('#')) continue; - - // Set which INI section we are in - if (lines[i].StartsWith('[') && lines[i].EndsWith(']')) - { - inSection = lines[i]; - continue; - } - - if (inSection == "[SYMBOL]") - { - string[] symbols = lines[i].Trim().Split(' '); - labelAddress = symbols[0].Replace(":", "").ToUpper(); // Remove bank colon - label.Name = symbols[1].Replace(".", "_"); // Replace dots which are valid in BSNES - } - else if (inSection == "[COMMENT]") - { - string[] comments = lines[i].Trim().Split(' ', 2); - labelAddress = comments[0].Replace(":", "").ToUpper(); // Remove bank colon - label.Comment = comments[1].Replace("\"", ""); // Remove quotes - } - } - // NOTE: this is kind of a risky way to parse CSV files, won't deal with weirdness in the comments - // section. replace with something better - else - { - Util.SplitOnFirstComma(lines[i], out labelAddress, out var remainder); - Util.SplitOnFirstComma(remainder, out var labelName, out var labelComment); - - label.Name = labelName.Trim(); - label.Comment = labelComment; - } - - if (!validLabelChars.Match(label.Name).Success) - throw new InvalidDataException("invalid label name: " + label.Name); - - var address = int.Parse(labelAddress, NumberStyles.HexNumber, null); - if (newValues.ContainsKey(address)) - { - // Update empty label properties instead of overwriting the entire object - // if there are multiple definitions (like from BSNES or handmade CSV) - var thisLabel = newValues[address]; - if (thisLabel.Name.IsEmpty()) thisLabel.Name = label.Name; - if (thisLabel.Comment.IsEmpty()) thisLabel.Comment = label.Comment; - } - else - { - newValues.Add(address, label); - } - } - - errLine = -1; - return newValues; - } - - public static void ImportLabelsFromCsv(this ILabelProvider labelProvider, string importFilename, bool replaceAll, ref int errLine) - { - var labelsFromCsv = ReadLabelsFromCsv(importFilename, out errLine); - - if (replaceAll) - labelProvider.DeleteAllLabels(); - - foreach (var (key, value) in labelsFromCsv) - { - labelProvider.AddLabel(key, value, true); - } - } - public static object SingleOrDefaultOfType(this IEnumerable enumerable, Type desiredType) { return enumerable.SingleOrDefault(item => item.GetType() == desiredType); diff --git a/Diz.Import/src/LabelImporter.cs b/Diz.Import/src/LabelImporter.cs new file mode 100644 index 00000000..eabb45be --- /dev/null +++ b/Diz.Import/src/LabelImporter.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Diz.Core.Interfaces; +using Diz.Core.model; +using Diz.Core.util; +using Diz.Import.bsnes; + +namespace Diz.Import; + +public abstract class LabelImporter +{ + // after import, if there was an error, this will be the line# of what it was. + // if -1, we parsed the entire file. + public int LastErrorLineNumber { get; private set; } = -1; + + // we don't modify labels in the open project directly, instead we read them + // into here and only return this on success. + private readonly Dictionary newLabels = new(); + + public virtual Dictionary ReadLabelsFromFile(string importFilename) + { + newLabels.Clear(); + LastErrorLineNumber = 0; + + var lineIndex = 0; + foreach (var line in Util.ReadLines(importFilename)) + { + LastErrorLineNumber = lineIndex + 1; + ParseLine(line); + lineIndex++; + } + + if (lineIndex == 0) + throw new InvalidDataException("No lines in file, can't import."); + + LastErrorLineNumber = -1; + return newLabels; + } + + private void ParseLine(string line) + { + var labelFound = TryParseLabelFromLine(line); + if (labelFound == null) + return; + + var (label, labelAddress) = labelFound.Value; + TryImportLabel(label, labelAddress); + } + + private void TryImportLabel(IAnnotationLabel label, string labelAddress) + { + var validLabelChars = new Regex(@"^([a-zA-Z0-9_\-]*)$"); + if (!validLabelChars.Match(label.Name).Success) + throw new InvalidDataException("invalid label name: " + label.Name); + + var address = int.Parse(labelAddress, NumberStyles.HexNumber, null); + if (!newLabels.ContainsKey(address)) + { + newLabels.Add(address, label); + } + else + { + // Update empty label properties instead of overwriting the entire object + // if there are multiple definitions (like from BSNES or handmade CSV) + var thisLabel = newLabels[address]; + + if (thisLabel.Name.IsEmpty()) + thisLabel.Name = label.Name; + + if (thisLabel.Comment.IsEmpty()) + thisLabel.Comment = label.Comment; + } + } + + protected abstract (IAnnotationLabel label, string labelAddress)? TryParseLabelFromLine(string line); +} + +public static class LabelImporterUtils +{ + // exception handling/line# stuff needs a little rework, messy. + public static void ImportLabelsFromCsv(this ILabelProvider labelProvider, string importFilename, bool replaceAll, out int errLine) + { + // could probably do this part more elegantly + errLine = 0; + LabelImporter? importer = null; + if (BsnesSymbolLabelImporter.IsFileCompatible(importFilename)) + { + importer = new BsnesSymbolLabelImporter(); + } + else if (LabelImporterCsv.IsFileCompatible(importFilename)) + { + importer = new LabelImporterCsv(); + } + + if (importer == null) + { + throw new InvalidDataException($"No importer was found that can import a file named:\n'{importFilename}'"); + } + + var labelsFromFile = importer.ReadLabelsFromFile(importFilename); + if (importer.LastErrorLineNumber != -1) + { + errLine = importer.LastErrorLineNumber; + throw new InvalidDataException( + $"Error importing file:\n'{importFilename}'\nNear line#: {importer.LastErrorLineNumber}"); + } + + if (replaceAll) + labelProvider.DeleteAllLabels(); + + labelProvider.AppendLabels(labelsFromFile); + } +} \ No newline at end of file diff --git a/Diz.Import/src/LabelImporterCsv.cs b/Diz.Import/src/LabelImporterCsv.cs new file mode 100644 index 00000000..3d27ee18 --- /dev/null +++ b/Diz.Import/src/LabelImporterCsv.cs @@ -0,0 +1,25 @@ +using Diz.Core.Interfaces; +using Diz.Core.model; +using Diz.Core.util; + +namespace Diz.Import; + +public class LabelImporterCsv : LabelImporter +{ + public static bool IsFileCompatible(string importFilename) => + importFilename.ToLower().EndsWith(".csv"); + + protected override (IAnnotationLabel label, string labelAddress)? TryParseLabelFromLine(string line) + { + // TODO: replace with something better. this is kind of a risky/fragile way to parse CSV lines. + // it won't deal with weirdness in the comments, quotes, etc. + Util.SplitOnFirstComma(line, out var labelAddress, out var remainder); + Util.SplitOnFirstComma(remainder, out var labelName, out var labelComment); + var label = new Label + { + Name = labelName.Trim(), + Comment = labelComment + }; + return (label, labelAddress); + } +} \ No newline at end of file diff --git a/Diz.Import/src/bsnes/BsnesSymbolLabelImporter.cs b/Diz.Import/src/bsnes/BsnesSymbolLabelImporter.cs new file mode 100644 index 00000000..6391e2bb --- /dev/null +++ b/Diz.Import/src/bsnes/BsnesSymbolLabelImporter.cs @@ -0,0 +1,85 @@ +using Diz.Core.Interfaces; +using Diz.Core.model; + +namespace Diz.Import.bsnes; + +// there's a few different flavors of .sym.cpu files. +// one is here: https://github.com/BenjaminSchulte/fma-snes65816/blob/master/docs/symbols.adoc +// another is from the BSNES+ debugger, which is slightly different. try and support both here if we can, or, split out the parser if needed. + +public class BsnesSymbolLabelImporter : LabelImporter +{ + private string currentBsnesSection = ""; + + public override Dictionary ReadLabelsFromFile(string importFilename) + { + currentBsnesSection = ""; + return base.ReadLabelsFromFile(importFilename); + } + + public static bool IsFileCompatible(string importFilename) + { + // Coming in from BSNES symbol map if it begins with the header + return importFilename.ToLower().EndsWith(".cpu.sym"); + + // here's another way to check if the file contents match. + // this signature can be present (but isn't always) present in some BSNES versions (it's not in BSNES+) + // the above filename extension check is probably sufficient for all of it though + // lines.Length > 0 && lines[0].StartsWith("#SNES65816"); + } + + protected override (IAnnotationLabel label, string labelAddress)? TryParseLabelFromLine(string line) + { + if (ShouldSkipLineBecauseCommentOrWhitespace(line)) + return null; + + // did we enter a new section? if so, note it, and move to next line + if (TryParseBsnesSection(line)) + return null; + + switch (currentBsnesSection) + { + case "[LABELS]": + case "[SYMBOL]": + { + var symbols = line.Trim().Split(' '); + var labelAddress = ParseSnesAddress(symbols[0]); + var label = new Label + { + Name = symbols[1].Replace(".", "_") // Replace dots which are valid in BSNES + }; + return (label, labelAddress); + } + case "[COMMENT]": + { + var comments = line.Trim().Split(' ', 2); + var labelAddress = ParseSnesAddress(comments[0]); + var label = new Label + { + Comment = comments[1].Replace("\"", "") // Remove quotes + }; + return (label, labelAddress); + } + } + + return null; + } + + private bool TryParseBsnesSection(string line) + { + // BSNES symbol files are multiple INI sections like "[symbol]" + // we only care about a few of them for Diztinguish + // if we hit a section header, consume it, keep going + if (!line.StartsWith('[') || !line.EndsWith(']')) + return false; + + currentBsnesSection = line.ToUpper(); + return true; + } + + private static string ParseSnesAddress(string symbols) => + symbols.Replace(":", "").ToUpper(); + + private static bool ShouldSkipLineBecauseCommentOrWhitespace(string line) => + line.Trim().Length == 0 || line.StartsWith('#') || line.StartsWith(';'); +} \ No newline at end of file diff --git a/DiztinGUIsh/window/AliasList.cs b/DiztinGUIsh/window/AliasList.cs index 3072f66f..b96dd23f 100644 --- a/DiztinGUIsh/window/AliasList.cs +++ b/DiztinGUIsh/window/AliasList.cs @@ -33,7 +33,8 @@ public AliasList() private void LabelsOnOnLabelChanged(object? sender, EventArgs e) { - // this is a bit hacky for the moment. be careful. better to use property notify change/etc later on. + // this is a bit hacky and very costly for lots of labels at the moment. + // be careful. better to replace with property notify change/etc later on. RepopulateFromData(); }