From 2e8f178b21898a391c32c6b0b55dbf994f25144b Mon Sep 17 00:00:00 2001 From: Joris van Eijden Date: Thu, 11 Jul 2024 11:15:52 +0200 Subject: [PATCH] Feature/structure viewer (#772) * Add a structure viewer to the memory viewer. * Don't crash without structure file * Fix hyperlink style * Replace fluent with semi * More progress towards a functional structure viewer * WIP progress, but stumped on hexview scroll issue * Resolve merge conflicts * update Structurizer * Replace iffy FileSystemWatcher with own poller implementation * Fix scroll issue with new document * Refactor and document StructureViewModel * Always allow address input * Formatting, styling and cleanup * Resolve merge conflicts --- src/Spice86.Core/CLI/Configuration.cs | 6 + .../Emulator/Function/IOverrideSupplier.cs | 1 + .../Emulator/Memory/SegmentedAddress.cs | 28 +++ .../Converters/SegmentedAddressConverter.cs | 38 ++++ .../DataTemplates/DataTemplateProvider.cs | 140 ++++++++++++++ .../ServiceCollectionExtensions.cs | 14 +- src/Spice86/Infrastructure/FilePoller.cs | 53 ++++++ .../StructureViewModelFactory.cs | 74 ++++++++ .../Interfaces/IStructureViewModelFactory.cs | 11 ++ src/Spice86/Models/AddressChangedMessage.cs | 5 + src/Spice86/Spice86.csproj | 178 ++++++++--------- src/Spice86/Styles/Spice86.axaml | 32 ++-- .../ViewModels/DebugWindowViewModel.cs | 18 +- src/Spice86/ViewModels/MainWindowViewModel.cs | 14 +- src/Spice86/ViewModels/MemoryViewModel.cs | 74 ++++++-- src/Spice86/ViewModels/StructureViewModel.cs | 179 ++++++++++++++++++ src/Spice86/Views/MemoryView.axaml | 28 ++- src/Spice86/Views/MemoryView.axaml.cs | 30 ++- src/Spice86/Views/StructureView.axaml | 97 ++++++++++ src/Spice86/Views/StructureView.axaml.cs | 25 +++ 20 files changed, 904 insertions(+), 141 deletions(-) create mode 100644 src/Spice86/Converters/SegmentedAddressConverter.cs create mode 100644 src/Spice86/DataTemplates/DataTemplateProvider.cs create mode 100644 src/Spice86/Infrastructure/FilePoller.cs create mode 100644 src/Spice86/Infrastructure/StructureViewModelFactory.cs create mode 100644 src/Spice86/Interfaces/IStructureViewModelFactory.cs create mode 100644 src/Spice86/Models/AddressChangedMessage.cs create mode 100644 src/Spice86/ViewModels/StructureViewModel.cs create mode 100644 src/Spice86/Views/StructureView.axaml create mode 100644 src/Spice86/Views/StructureView.axaml.cs diff --git a/src/Spice86.Core/CLI/Configuration.cs b/src/Spice86.Core/CLI/Configuration.cs index 6de5132aa..35986936f 100644 --- a/src/Spice86.Core/CLI/Configuration.cs +++ b/src/Spice86.Core/CLI/Configuration.cs @@ -137,4 +137,10 @@ public sealed class Configuration { /// [Option(nameof(Mouse), Default = MouseType.Ps2, Required = false, HelpText = "Specify the type of mouse to use. Valid values are None, PS2 (default), and PS2Wheel")] public MouseType Mouse { get; init; } + + /// + /// Specify a C header file to be used for structure information + /// + [Option(nameof(StructureFile), Default = null, Required = false, HelpText = "Specify a C header file to be used for structure information")] + public string? StructureFile { get; init; } } \ No newline at end of file diff --git a/src/Spice86.Core/Emulator/Function/IOverrideSupplier.cs b/src/Spice86.Core/Emulator/Function/IOverrideSupplier.cs index 0455a786b..56803f538 100644 --- a/src/Spice86.Core/Emulator/Function/IOverrideSupplier.cs +++ b/src/Spice86.Core/Emulator/Function/IOverrideSupplier.cs @@ -16,6 +16,7 @@ public interface IOverrideSupplier { /// The configuration. /// The start address of the program. /// The emulator machine. + /// /// A dictionary containing the generated function information overrides. public IDictionary GenerateFunctionInformations( ILoggerService loggerService, diff --git a/src/Spice86.Shared/Emulator/Memory/SegmentedAddress.cs b/src/Spice86.Shared/Emulator/Memory/SegmentedAddress.cs index 440df8d85..d8ec304f6 100644 --- a/src/Spice86.Shared/Emulator/Memory/SegmentedAddress.cs +++ b/src/Spice86.Shared/Emulator/Memory/SegmentedAddress.cs @@ -3,6 +3,8 @@ using Spice86.Shared.Utils; using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json.Serialization; /// @@ -140,4 +142,30 @@ public void Deconstruct(out ushort segment, out ushort offset) { segment = Segment; offset = Offset; } + + /// + /// Tries to parse a hexadecimal string in the format of segment:offset into a SegmentedAddress object. + /// + /// a hex string in the format of segment:offset + /// + /// true if s was converted successfully; otherwise, false. + public static bool TryParse(string? s, [NotNullWhen(true)] out SegmentedAddress segmentedAddress) { + if (string.IsNullOrWhiteSpace(s)) { + segmentedAddress = default; + + return false; + } + string[] split = s.Split(":"); + if (split.Length == 2 + && ushort.TryParse(split[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort segment) + && ushort.TryParse(split[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort offset)) { + segmentedAddress = new SegmentedAddress(segment, offset); + + return true; + } + + segmentedAddress = default; + + return false; + } } \ No newline at end of file diff --git a/src/Spice86/Converters/SegmentedAddressConverter.cs b/src/Spice86/Converters/SegmentedAddressConverter.cs new file mode 100644 index 000000000..066708a9f --- /dev/null +++ b/src/Spice86/Converters/SegmentedAddressConverter.cs @@ -0,0 +1,38 @@ +namespace Spice86.Converters; + +using Avalonia.Data; +using Avalonia.Data.Converters; + +using Spice86.Shared.Emulator.Memory; + +using System.Globalization; +using System.Text.RegularExpressions; + +public partial class SegmentedAddressConverter : IValueConverter { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { + return value switch { + null => null, + SegmentedAddress segmentedAddress => $"{segmentedAddress.Segment:X4}:{segmentedAddress.Offset:X4}", + _ => new BindingNotification(new InvalidCastException(), BindingErrorType.Error) + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { + if (value is not string str || string.IsNullOrWhiteSpace(str)) { + return new SegmentedAddress(); + } + + Match match = SegmentedAddressRegex().Match(str); + if (match.Success) { + return new SegmentedAddress( + ushort.Parse(match.Groups[1].Value, NumberStyles.HexNumber, culture), + ushort.Parse(match.Groups[2].Value, NumberStyles.HexNumber, culture) + ); + } + + return null; + } + + [GeneratedRegex(@"^([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})$")] + private static partial Regex SegmentedAddressRegex(); +} \ No newline at end of file diff --git a/src/Spice86/DataTemplates/DataTemplateProvider.cs b/src/Spice86/DataTemplates/DataTemplateProvider.cs new file mode 100644 index 000000000..eaff0422e --- /dev/null +++ b/src/Spice86/DataTemplates/DataTemplateProvider.cs @@ -0,0 +1,140 @@ +namespace Spice86.DataTemplates; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Layout; +using Avalonia.Media; + +using CommunityToolkit.Mvvm.Input; + +using Structurizer.Types; + +using System.Text; + +public static class DataTemplateProvider { + public static FuncDataTemplate StructureMemberValueTemplate { get; } = new(BuildStructureMemberValuePresenter); + + private static Control? BuildStructureMemberValuePresenter(StructureMember? structureMember, INameScope scope) { + if (structureMember is null) { + return null; + } + if (structureMember.Type is {IsPointer: true, IsArray: false}) { + return new Button { + Content = FormatPointer(structureMember), + Command = new RelayCommand(() => throw new NotImplementedException("This should open a new memory view at the address the pointer points to")), + Classes = {"hyperlink"}, + HorizontalAlignment = HorizontalAlignment.Right, + HorizontalContentAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0,0,5,0) + }; + } + + return new TextBlock { + Text = FormatValue(structureMember), + TextAlignment = TextAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0,0,5,0) + }; + } + + private static string FormatValue(StructureMember structureMember) { + if (structureMember.Members != null && structureMember.Type.Type != "char") { + return string.Empty; + } + if (structureMember.Type.EnumType != null) { + return FormatEnum(structureMember.Type.EnumType, structureMember.Data); + } + if (structureMember.Type is {IsPointer: true, Count: 1}) { + return FormatPointer(structureMember); + } + + return structureMember.Type.Type switch { + "char" when structureMember.Type.IsArray => '"' + Encoding.ASCII.GetString(structureMember.Data) + '"', + "__int8" or "char" or "_BYTE" => FormatChar(structureMember.Data[0]), + "__int16" or "short" or "int" when structureMember.Type.Unsigned => FormatUnsignedShort(structureMember), + "__int16" or "short" or "int" => FormatShort(structureMember), + "__int32" or "long" when structureMember.Type.Unsigned => FormatUnsignedLong(structureMember), + "__int32" or "long" => FormatLong(structureMember), + _ => FormatHex(structureMember) + }; + } + + private static string FormatHex(StructureMember structureMember) { + switch (structureMember.Size) { + case 1: + return $"0x{structureMember.Data[0]:X2}"; + case 2: + return $"0x{BitConverter.ToUInt16(structureMember.Data):X4}"; + case 4: + return $"0x{BitConverter.ToUInt32(structureMember.Data):X8}"; + default: + return "???"; + } + } + + private static string FormatPointer(StructureMember structureMember) { + Span bytes = structureMember.Data.AsSpan(); + ushort targetSegment; + ushort targetOffset; + if (structureMember.Type.IsNear && bytes.Length == 2) { + targetSegment = 0; + targetOffset = BitConverter.ToUInt16(bytes); + } else if (bytes.Length == 4) { + targetSegment = BitConverter.ToUInt16(bytes[2..]); + targetOffset = BitConverter.ToUInt16(bytes[..2]); + } else { + throw new ArgumentException($"Invalid pointer size: {bytes.Length * 8}"); + } + + return structureMember.Type.IsNear + ? $"DS:{targetOffset:X4}" + : $"{targetSegment:X4}:{targetOffset:X4}"; + } + + private static string FormatEnum(EnumType enumType, byte[] bytes) { + uint value = enumType.MemberSize switch { + 1 => bytes[0], + 2 => BitConverter.ToUInt16(bytes), + 4 => BitConverter.ToUInt32(bytes), + _ => throw new NotSupportedException($"Enum member size {enumType.MemberSize} not supported") + }; + + if (enumType.Members.TryGetValue(value, out string? name)) { + return $"{name} [0x{value:X}]"; + } + + throw new ArgumentOutOfRangeException(nameof(bytes), $"Enum value {value} not found in enum"); + } + + private static string FormatLong(StructureMember structureMember) { + int value = BitConverter.ToInt32(structureMember.Data); + + return $"{value} [0x{value:X8}]"; + } + + private static string FormatUnsignedLong(StructureMember structureMember) { + uint value = BitConverter.ToUInt32(structureMember.Data); + + return $"{value} [0x{value:X8}]"; + } + + private static string FormatShort(StructureMember structureMember) { + short value = BitConverter.ToInt16(structureMember.Data); + + return $"{value} [0x{value:X4}]"; + } + + private static string FormatUnsignedShort(StructureMember structureMember) { + ushort value = BitConverter.ToUInt16(structureMember.Data); + + return $"{value} [0x{value:X4}]"; + } + + private static string FormatChar(byte value) { + return value is < 32 or > 126 + ? $"{value} [0x{value:X2}]" + : $"{value} '{(char)value}' [0x{value:X2}]"; + } +} \ No newline at end of file diff --git a/src/Spice86/DependencyInjection/ServiceCollectionExtensions.cs b/src/Spice86/DependencyInjection/ServiceCollectionExtensions.cs index c32e6e61f..84d2e6552 100644 --- a/src/Spice86/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Spice86/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ namespace Spice86.DependencyInjection; using Spice86.Core.CLI; using Spice86.Infrastructure; +using Spice86.Interfaces; using Spice86.Logging; using Spice86.Shared.Interfaces; @@ -16,27 +17,30 @@ public static void AddConfiguration(this IServiceCollection serviceCollection, s serviceCollection.AddSingleton(); serviceCollection.AddSingleton(serviceProvider => { ICommandLineParser commandLineParser = serviceProvider.GetRequiredService(); + return commandLineParser.ParseCommandLine(args); }); } public static void AddLogging(this IServiceCollection serviceCollection) { serviceCollection.AddSingleton(); - serviceCollection.AddSingleton((serviceProvider) => { + serviceCollection.AddSingleton(serviceProvider => { Configuration configuration = serviceProvider.GetRequiredService(); LoggerService loggerService = new LoggerService(serviceProvider.GetRequiredService()); Startup.SetLoggingLevel(loggerService, configuration); + return loggerService; }); } - + public static void AddGuiInfrastructure(this IServiceCollection serviceCollection, TopLevel mainWindow) { serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton((_) => new UIDispatcher(Dispatcher.UIThread)); + serviceCollection.AddSingleton(_ => new UIDispatcher(Dispatcher.UIThread)); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton((_) => mainWindow.StorageProvider); + serviceCollection.AddSingleton(_ => mainWindow.StorageProvider); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton((_) => new TextClipboard(mainWindow.Clipboard)); + serviceCollection.AddSingleton(_ => new TextClipboard(mainWindow.Clipboard)); + serviceCollection.AddSingleton(); } } \ No newline at end of file diff --git a/src/Spice86/Infrastructure/FilePoller.cs b/src/Spice86/Infrastructure/FilePoller.cs new file mode 100644 index 000000000..079a4b7ee --- /dev/null +++ b/src/Spice86/Infrastructure/FilePoller.cs @@ -0,0 +1,53 @@ +namespace Spice86.Infrastructure; + +using PropertyModels.Extensions; + +using System; +using System.IO; +using System.Timers; + +public class FilePoller { + private readonly string _filePath; + private ulong _lastHash; + private bool _fileChanged; + private readonly Timer _timer; + private readonly Action _actionToPerform; + + public FilePoller(string filePath, Action actionToPerformOnChange, double interval = 1000) { + _filePath = filePath; + _actionToPerform = actionToPerformOnChange; + _timer = new Timer(interval); + _timer.Elapsed += CheckFileChange; + _timer.AutoReset = true; + } + + public void Start() { + _timer.Start(); + } + + public void Stop() { + _timer.Stop(); + } + + private void CheckFileChange(object? sender, ElapsedEventArgs e) { + if (!File.Exists(_filePath)) + return; + + ulong currentHash = CalculateHash(_filePath); + + if (_lastHash != 0 && _lastHash != currentHash) { + _fileChanged = !_fileChanged; + } else if (_lastHash == currentHash && _fileChanged) { + _fileChanged = false; + _actionToPerform(); + } + + _lastHash = currentHash; + } + + private static ulong CalculateHash(string filePath) { + string fileContents = File.ReadAllText(filePath); + + return fileContents.GetDeterministicHashCode64(); + } +} \ No newline at end of file diff --git a/src/Spice86/Infrastructure/StructureViewModelFactory.cs b/src/Spice86/Infrastructure/StructureViewModelFactory.cs new file mode 100644 index 000000000..9031b0354 --- /dev/null +++ b/src/Spice86/Infrastructure/StructureViewModelFactory.cs @@ -0,0 +1,74 @@ +namespace Spice86.Infrastructure; + +using AvaloniaHex.Document; + +using Spice86.Core.CLI; +using Spice86.Interfaces; +using Spice86.Shared.Interfaces; +using Spice86.ViewModels; + +using Structurizer; +using Structurizer.Types; + +public class StructureViewModelFactory : IStructureViewModelFactory { + private readonly Hydrator? _hydrator; + private readonly Parser? _parser; + private readonly StructurizerSettings _structurizerSettings = new(); + private StructureInformation? _structureInformation; + private readonly Configuration _configuration; + private readonly ILoggerService _logger; + + public event EventHandler? StructureInformationChanged; + + public StructureViewModelFactory(Configuration configuration, ILoggerService logger) { + _logger = logger; + _configuration = configuration; + if (!TryGetHeaderFilePath(out string headerFilePath)) { + return; + } + _parser = new Parser(_structurizerSettings); + _hydrator = new Hydrator(_structurizerSettings); + + Parse(headerFilePath); + var poller = new FilePoller(headerFilePath, () => Parse(headerFilePath)); + poller.Start(); + } + + public bool IsInitialized => _structureInformation != null && _hydrator != null; + + public StructureViewModel CreateNew(IBinaryDocument data) { + if (_structureInformation == null || _hydrator == null) { + throw new InvalidOperationException("Factory not initialized."); + } + + var viewModel = new StructureViewModel(_structureInformation, _hydrator, data); + StructureInformationChanged += viewModel.OnStructureInformationChanged; + + return viewModel; + } + + public void Parse(string headerFilePath) { + _logger.Information("Parsing {HeaderFilePath} for structure information", headerFilePath); + if (_parser == null) { + throw new InvalidOperationException("Factory not initialized."); + } + if (!File.Exists(headerFilePath)) { + throw new FileNotFoundException($"Specified structure file not found: '{headerFilePath}'"); + } + + string source = File.ReadAllText(headerFilePath); + + _structureInformation = _parser.ParseSource(source); + StructureInformationChanged?.Invoke(this, EventArgs.Empty); + } + + private bool TryGetHeaderFilePath(out string headerFilePath) { + headerFilePath = string.Empty; + if (string.IsNullOrWhiteSpace(_configuration.StructureFile)) { + return false; + } + headerFilePath = _configuration.StructureFile; + + return true; + } +} \ No newline at end of file diff --git a/src/Spice86/Interfaces/IStructureViewModelFactory.cs b/src/Spice86/Interfaces/IStructureViewModelFactory.cs new file mode 100644 index 000000000..d824c3a19 --- /dev/null +++ b/src/Spice86/Interfaces/IStructureViewModelFactory.cs @@ -0,0 +1,11 @@ +namespace Spice86.Interfaces; + +using AvaloniaHex.Document; + +using Spice86.ViewModels; + +public interface IStructureViewModelFactory { + bool IsInitialized { get; } + StructureViewModel CreateNew(IBinaryDocument data); + void Parse(string headerFilePath); +} \ No newline at end of file diff --git a/src/Spice86/Models/AddressChangedMessage.cs b/src/Spice86/Models/AddressChangedMessage.cs new file mode 100644 index 000000000..e0cb0535b --- /dev/null +++ b/src/Spice86/Models/AddressChangedMessage.cs @@ -0,0 +1,5 @@ +namespace Spice86.Models; + +public class AddressChangedMessage(ulong address) { + public ulong Address { get; } = address; +} \ No newline at end of file diff --git a/src/Spice86/Spice86.csproj b/src/Spice86/Spice86.csproj index 048ed7325..e5055afee 100644 --- a/src/Spice86/Spice86.csproj +++ b/src/Spice86/Spice86.csproj @@ -1,93 +1,97 @@  - - Exe - net8.0 - enable - nullable - enable - true - True - 1591;1572;1573;1570;1587;1574 - true - app.manifest - true - - - - CS1591 - Spice86 - true - 7.0.0 - Some breaking API changes (SegmentRegisters.cs), WIP new CFG_CPU, addtionnal memory/disasm views to the internal debugger, replaced UI DI framework with Microsoft.DI. + + Exe + net8.0 + enable + nullable + enable + true + True + 1591;1572;1573;1570;1587;1574 + true + app.manifest + true + + + + CS1591 + Spice86 + true + 7.0.0 + Some breaking API changes (SegmentRegisters.cs), WIP new CFG_CPU, addtionnal memory/disasm views to the internal debugger, replaced UI DI framework with Microsoft.DI. Kevin Ferrare, Maximilien Noal, Joris van Eijden, Artjom Vejsel - Apache-2.0 - Reverse engineer and rewrite real mode DOS programs - reverse-engineering;avalonia;debugger;assembly;emulator;cross-platform - https://github.com/OpenRakis/Spice86 - https://github.com/OpenRakis/Spice86 - git - - - - lib\net8.0\libportaudio.dll - Always - True - - + Apache-2.0 + Reverse engineer and rewrite real mode DOS programs + reverse-engineering;avalonia;debugger;assembly;emulator;cross-platform + https://github.com/OpenRakis/Spice86 + https://github.com/OpenRakis/Spice86 + git + - - - true - true - true - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + true + true + true + - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - + + + lib\net8.0\libportaudio.dll + Always + True + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/Spice86/Styles/Spice86.axaml b/src/Spice86/Styles/Spice86.axaml index 8f72dff8b..1c8a7fbef 100644 --- a/src/Spice86/Styles/Spice86.axaml +++ b/src/Spice86/Styles/Spice86.axaml @@ -2,23 +2,21 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - -