diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59e6eb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/bin/** +**/obj/** diff --git a/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml b/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml index 368f71c..a47b547 100644 --- a/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml +++ b/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml @@ -3,7 +3,9 @@ diff --git a/GsaEditor.Core/IO/GsaIndexReader.cs b/GsaEditor.Core/IO/GsaIndexReader.cs index 5937e0b..beffa0e 100644 --- a/GsaEditor.Core/IO/GsaIndexReader.cs +++ b/GsaEditor.Core/IO/GsaIndexReader.cs @@ -1,4 +1,4 @@ -using System.Xml.Linq; +using System.Text.RegularExpressions; namespace GsaEditor.Core.IO; @@ -34,10 +34,20 @@ public class GsaIndexEntry } /// -/// Parses .idx NML index files (XML-compatible format) into a list of . +/// Parses .idx NML index files into a list of . +/// NML is the engine's own tag syntax (<Name=value> blocks, see ), +/// not standard XML. /// public static class GsaIndexReader { + private static readonly Regex EntryRegex = new( + @".*?)\n\s*>", + RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex TagRegex = new( + @"<(?\w+)=""?(?[^"">]*)""?>", + RegexOptions.Compiled); + /// /// Reads index entries from the specified .idx file. /// @@ -45,23 +55,36 @@ public static class GsaIndexReader /// A list of parsed index entries. public static List Read(string filePath) { - var doc = XDocument.Load(filePath); + var text = File.ReadAllText(filePath); var entries = new List(); - var root = doc.Root; - if (root == null || root.Name.LocalName != "Index") - return entries; - - foreach (var entryEl in root.Elements("Entry")) + foreach (Match entryMatch in EntryRegex.Matches(text)) { - var entry = new GsaIndexEntry + var entry = new GsaIndexEntry(); + + foreach (Match tag in TagRegex.Matches(entryMatch.Groups["body"].Value)) { - Id = entryEl.Element("Id")?.Value ?? string.Empty, - Method = int.TryParse(entryEl.Element("Method")?.Value, out var m) ? m : 0, - Length = uint.TryParse(entryEl.Element("Len")?.Value, out var l) ? l : 0, - CompressedLength = uint.TryParse(entryEl.Element("CompLen")?.Value, out var cl) ? cl : 0, - Offset = long.TryParse(entryEl.Element("Offset")?.Value, out var o) ? o : 0 - }; + var value = tag.Groups["value"].Value; + switch (tag.Groups["name"].Value) + { + case "ID": + entry.Id = value; + break; + case "Method": + entry.Method = value.Equals("Zlib", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + break; + case "Len": + entry.Length = uint.TryParse(value, out var l) ? l : 0; + break; + case "CompLen": + entry.CompressedLength = uint.TryParse(value, out var cl) ? cl : 0; + break; + case "Offset": + entry.Offset = long.TryParse(value, out var o) ? o : 0; + break; + } + } + entries.Add(entry); } diff --git a/GsaEditor.Core/IO/GsaIndexWriter.cs b/GsaEditor.Core/IO/GsaIndexWriter.cs index f462cae..e6604ee 100644 --- a/GsaEditor.Core/IO/GsaIndexWriter.cs +++ b/GsaEditor.Core/IO/GsaIndexWriter.cs @@ -1,36 +1,62 @@ -using System.Xml.Linq; +using System.Text; using GsaEditor.Core.Models; namespace GsaEditor.Core.IO; /// /// Writes a .idx NML index file from a . -/// The index file is XML-compatible and lists each entry with its alias, compression method, -/// sizes, and data offset for fast lookup without a full sequential scan. +/// The NML format is the engine's own tag syntax (NOT standard XML): +/// +/// <Version=1.0> +/// <Index= +/// <Entry= +/// <ID="path/in/archive"> +/// <CompLen=123> +/// <Len=456> +/// <Offset=789> +/// <Method="Zlib"> +/// > +/// > +/// +/// Tab indentation, LF line endings, no BOM. Method is "Zlib" or "Raw". +/// The engine parses this file to locate entries, so the format must match exactly. /// public static class GsaIndexWriter { + /// + /// The engine's bootstrap script is loaded by sequential archive scan and is + /// deliberately absent from the shipped index files, so it is skipped here too. + /// + private const string BootstrapAlias = "bootstrap.txt"; + /// /// Writes the index file for the given archive to the specified path. + /// Entry offsets must be up to date (i.e. call after ). /// /// The archive whose entries will be indexed. /// The output .idx file path. public static void Write(GsaArchive archive, string filePath) { - var doc = new XDocument( - new XElement("Index", - archive.Entries.Select(e => - new XElement("Entry", - new XElement("Id", e.Alias), - new XElement("Method", e.IsCompressed ? 1 : 0), - new XElement("Len", e.OriginalLength), - new XElement("CompLen", e.CompressedLength), - new XElement("Offset", e.DataOffset) - ) - ) - ) - ); + var sb = new StringBuilder(); + sb.Append("\n"); + sb.Append("\n"); + sb.Append("\t\t\n"); + sb.Append("\t\t\n"); + sb.Append("\t\t\n"); + sb.Append("\t\t\n"); + sb.Append("\t>\n"); + } + + sb.Append(">\n"); + + File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } } diff --git a/GsaEditor.Core/IO/GsaWriter.cs b/GsaEditor.Core/IO/GsaWriter.cs index 333474c..3d29e18 100644 --- a/GsaEditor.Core/IO/GsaWriter.cs +++ b/GsaEditor.Core/IO/GsaWriter.cs @@ -15,6 +15,12 @@ public static class GsaWriter /// Magic word for the NARD (enhanced) format. private const uint MAGIC_NARD = 0x4E415244; + /// + /// End-of-archive terminator written where the next entry's alias length would be. + /// The engine's sequential reader stops when it hits this invalid length. + /// + private const uint TERMINATOR = 0xFFFFFFFF; + /// /// Writes the archive to the specified file path. /// @@ -53,6 +59,11 @@ public static class GsaWriter { WriteEntry(writer, entry, archive); } + + // Write the end-of-archive terminator, aligned like an entry header + if (archive.Format == GsaFormat.NARD && archive.OffsetPadding > 0) + WritePadding(writer, archive.OffsetPadding); + writer.Write(TERMINATOR); } /// diff --git a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.dll b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.dll index 7e45f4a..d7d1423 100644 Binary files a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.dll and b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.dll differ diff --git a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.pdb b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.pdb index 3abb27e..383ce41 100644 Binary files a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.pdb and b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.pdb differ diff --git a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.xml b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.xml index 79f2155..30a5815 100644 --- a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.xml +++ b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.xml @@ -67,7 +67,9 @@ - Parses .idx NML index files (XML-compatible format) into a list of . + Parses .idx NML index files into a list of . + NML is the engine's own tag syntax (<Name=value> blocks, see ), + not standard XML. @@ -80,13 +82,33 @@ Writes a .idx NML index file from a . - The index file is XML-compatible and lists each entry with its alias, compression method, - sizes, and data offset for fast lookup without a full sequential scan. + The NML format is the engine's own tag syntax (NOT standard XML): + + <Version=1.0> + <Index= + <Entry= + <ID="path/in/archive"> + <CompLen=123> + <Len=456> + <Offset=789> + <Method="Zlib"> + > + > + + Tab indentation, LF line endings, no BOM. Method is "Zlib" or "Raw". + The engine parses this file to locate entries, so the format must match exactly. + + + + + The engine's bootstrap script is loaded by sequential archive scan and is + deliberately absent from the shipped index files, so it is skipped here too. Writes the index file for the given archive to the specified path. + Entry offsets must be up to date (i.e. call after ). The archive whose entries will be indexed. The output .idx file path. @@ -149,6 +171,12 @@ Magic word for the NARD (enhanced) format. + + + End-of-archive terminator written where the next entry's alias length would be. + The engine's sequential reader stops when it hits this invalid length. + + Writes the archive to the specified file path. diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs index 44a6762..e4e60c7 100644 --- a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs @@ -13,10 +13,10 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("GsaEditor.Core")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+d6d621dc92b3083d8e47827baa0ccf59d5b0a4c4")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+cbf0891ba467ebec2dd40e9b34eb1f2d6379d7dd")] [assembly: System.Reflection.AssemblyProductAttribute("GsaEditor.Core")] [assembly: System.Reflection.AssemblyTitleAttribute("GsaEditor.Core")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] -// Généré par la classe MSBuild WriteCodeFragment. +// Generated by the MSBuild WriteCodeFragment class. diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache index f61127c..e346c7e 100644 --- a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache @@ -1 +1 @@ -27416b735d6b5e6056e4f76966d345ebebb5a18e412e4170cdbcf381f9ab7092 +24bad01704fb232d5dd4f84bfcb34e9e1b11dc1475b9ee697c24bd2eb1122246 diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.dll b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.dll index 7e45f4a..d7d1423 100644 Binary files a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.dll and b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.dll differ diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.pdb b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.pdb index 3abb27e..383ce41 100644 Binary files a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.pdb and b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.pdb differ diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.xml b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.xml index 79f2155..30a5815 100644 --- a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.xml +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.xml @@ -67,7 +67,9 @@ - Parses .idx NML index files (XML-compatible format) into a list of . + Parses .idx NML index files into a list of . + NML is the engine's own tag syntax (<Name=value> blocks, see ), + not standard XML. @@ -80,13 +82,33 @@ Writes a .idx NML index file from a . - The index file is XML-compatible and lists each entry with its alias, compression method, - sizes, and data offset for fast lookup without a full sequential scan. + The NML format is the engine's own tag syntax (NOT standard XML): + + <Version=1.0> + <Index= + <Entry= + <ID="path/in/archive"> + <CompLen=123> + <Len=456> + <Offset=789> + <Method="Zlib"> + > + > + + Tab indentation, LF line endings, no BOM. Method is "Zlib" or "Raw". + The engine parses this file to locate entries, so the format must match exactly. + + + + + The engine's bootstrap script is loaded by sequential archive scan and is + deliberately absent from the shipped index files, so it is skipped here too. Writes the index file for the given archive to the specified path. + Entry offsets must be up to date (i.e. call after ). The archive whose entries will be indexed. The output .idx file path. @@ -149,6 +171,12 @@ Magic word for the NARD (enhanced) format. + + + End-of-archive terminator written where the next entry's alias length would be. + The engine's sequential reader stops when it hits this invalid length. + + Writes the archive to the specified file path. diff --git a/GsaEditor.Core/obj/Debug/net8.0/ref/GsaEditor.Core.dll b/GsaEditor.Core/obj/Debug/net8.0/ref/GsaEditor.Core.dll index 3b897f7..4b083fb 100644 Binary files a/GsaEditor.Core/obj/Debug/net8.0/ref/GsaEditor.Core.dll and b/GsaEditor.Core/obj/Debug/net8.0/ref/GsaEditor.Core.dll differ diff --git a/GsaEditor.Core/obj/Debug/net8.0/refint/GsaEditor.Core.dll b/GsaEditor.Core/obj/Debug/net8.0/refint/GsaEditor.Core.dll index 3b897f7..4b083fb 100644 Binary files a/GsaEditor.Core/obj/Debug/net8.0/refint/GsaEditor.Core.dll and b/GsaEditor.Core/obj/Debug/net8.0/refint/GsaEditor.Core.dll differ diff --git a/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs b/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs index b68f02a..cca24e4 100644 --- a/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs +++ b/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs @@ -13,6 +13,9 @@ public partial class EntryTreeNodeViewModel : ViewModelBase [ObservableProperty] private string _name = string.Empty; + [ObservableProperty] + private bool _isExpanded; + /// /// Full relative path of this node within the archive (using '/' separator). /// diff --git a/GsaEditor/ViewModels/MainWindowViewModel.cs b/GsaEditor/ViewModels/MainWindowViewModel.cs index fad9cbd..e84e2a5 100644 --- a/GsaEditor/ViewModels/MainWindowViewModel.cs +++ b/GsaEditor/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using Avalonia.Controls; using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; @@ -84,8 +85,24 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private bool _isEditingText; + [ObservableProperty] + private string? _searchQuery; + + [ObservableProperty] + private bool _searchInContents; + + [ObservableProperty] + private string? _searchStatus; + + [ObservableProperty] + private SearchResultViewModel? _selectedSearchResult; + public ObservableCollection TreeRoots { get; } = new(); + public ObservableCollection SearchResults { get; } = new(); + + public bool HasSearchResults => SearchResults.Count > 0; + // --- Derived properties --- public string Title => ArchivePath != null @@ -255,6 +272,8 @@ public partial class MainWindowViewModel : ViewModelBase _archive = archive; ArchivePath = path; IsDirty = false; + SearchQuery = null; + ClearSearchResults(); BuildTree(); NotifyStatusChanged(); } @@ -347,6 +366,8 @@ public partial class MainWindowViewModel : ViewModelBase TreeRoots.Clear(); SelectedEntry = null; SelectedNode = null; + SearchQuery = null; + ClearSearchResults(); ClearPreview(); NotifyStatusChanged(); } @@ -663,6 +684,215 @@ public partial class MainWindowViewModel : ViewModelBase } } + [RelayCommand] + private async Task OpenAsText() + { + if (SelectedNode?.Entry == null) return; + + var data = SelectedNode.Entry.GetDecompressedData(); + + try + { + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(data); + } + catch (DecoderFallbackException) + { + if (_window != null) + { + var confirmed = await Dialogs.ConfirmAsync(_window, "Not a Text File", + "This file is not valid UTF-8 text. Editing and saving it as text will corrupt its content.\n\nOpen as text anyway?"); + if (!confirmed) return; + } + } + + PreviewMode = PreviewMode.Text; + PreviewText = Encoding.UTF8.GetString(data); + PreviewImage?.Dispose(); + PreviewImage = null; + HexDumpText = null; + } + + // ========================================================================= + // Search + // ========================================================================= + + private const int MaxNameResults = 200; + private const int MaxContentResults = 1000; + + partial void OnSearchQueryChanged(string? value) + { + // Name search is live; content search only runs on Enter / the Search button + if (!SearchInContents) + RunNameSearch(); + else if (string.IsNullOrWhiteSpace(value)) + ClearSearchResults(); + } + + partial void OnSearchInContentsChanged(bool value) + { + if (!value) + RunNameSearch(); + else + ClearSearchResults(); + } + + partial void OnSelectedSearchResultChanged(SearchResultViewModel? value) + { + if (value != null && value.Alias.Length > 0) + SelectEntryNode(value.Alias); + } + + [RelayCommand] + private async Task Search() + { + if (_archive == null) return; + + if (!SearchInContents) + { + RunNameSearch(); + return; + } + + if (string.IsNullOrEmpty(SearchQuery)) return; + + Regex regex; + try + { + regex = new Regex(SearchQuery, RegexOptions.None, TimeSpan.FromSeconds(5)); + } + catch (ArgumentException ex) + { + if (_window != null) + await Dialogs.ShowMessageAsync(_window, "Invalid Regex", $"Invalid regular expression:\n{ex.Message}"); + return; + } + + IsLoading = true; + try + { + var entries = _archive.Entries.ToList(); + var results = await Task.Run(() => + { + var found = new List(); + foreach (var entry in entries) + { + string text; + try + { + text = Encoding.UTF8.GetString(entry.GetDecompressedData()); + } + catch + { + continue; + } + + int lineNo = 0; + foreach (var rawLine in text.Split('\n')) + { + lineNo++; + var line = rawLine.TrimEnd('\r'); + + bool isMatch; + try + { + isMatch = regex.IsMatch(line); + } + catch (RegexMatchTimeoutException) + { + break; + } + + if (!isMatch) continue; + + var snippet = line.Trim(); + if (snippet.Length > 120) snippet = snippet.Substring(0, 120) + "…"; + found.Add(new SearchResultViewModel + { + Alias = entry.Alias, + Detail = $"line {lineNo}: {snippet}" + }); + + if (found.Count >= MaxContentResults) + return found; + } + } + return found; + }); + + SearchResults.Clear(); + foreach (var r in results) + SearchResults.Add(r); + OnPropertyChanged(nameof(HasSearchResults)); + + SearchStatus = results.Count >= MaxContentResults + ? $"{results.Count} matches (stopped at {MaxContentResults})" + : $"{results.Count} match(es)"; + } + finally + { + IsLoading = false; + } + } + + private void RunNameSearch() + { + SearchResults.Clear(); + + var query = SearchQuery; + if (_archive == null || string.IsNullOrWhiteSpace(query)) + { + SearchStatus = null; + OnPropertyChanged(nameof(HasSearchResults)); + return; + } + + int total = 0; + foreach (var entry in _archive.Entries) + { + if (!entry.Alias.Contains(query, StringComparison.OrdinalIgnoreCase)) continue; + total++; + if (SearchResults.Count < MaxNameResults) + SearchResults.Add(new SearchResultViewModel { Alias = entry.Alias }); + } + + SearchStatus = total > MaxNameResults + ? $"{total} file(s) (showing first {MaxNameResults})" + : $"{total} file(s)"; + OnPropertyChanged(nameof(HasSearchResults)); + } + + private void ClearSearchResults() + { + SearchResults.Clear(); + SearchStatus = null; + SelectedSearchResult = null; + OnPropertyChanged(nameof(HasSearchResults)); + } + + /// + /// Selects the tree node matching the given alias, expanding parent folders along the way. + /// + private void SelectEntryNode(string alias) + { + var parts = alias.Split('/'); + var level = TreeRoots; + EntryTreeNodeViewModel? node = null; + + foreach (var part in parts) + { + node = level.FirstOrDefault(n => n.Name == part); + if (node == null) return; + if (node.IsFolder) + { + node.IsExpanded = true; + level = node.Children; + } + } + + if (node != null && !node.IsFolder) + SelectedNode = node; + } + // ========================================================================= // About // ========================================================================= diff --git a/GsaEditor/ViewModels/SearchResultViewModel.cs b/GsaEditor/ViewModels/SearchResultViewModel.cs new file mode 100644 index 0000000..b856ddb --- /dev/null +++ b/GsaEditor/ViewModels/SearchResultViewModel.cs @@ -0,0 +1,19 @@ +namespace GsaEditor.ViewModels; + +/// +/// A single search hit: a file name match, or a matching line inside a file. +/// +public class SearchResultViewModel +{ + /// + /// Alias of the matching archive entry. + /// + public string Alias { get; init; } = string.Empty; + + /// + /// Match context for content searches (e.g. "line 12: ..."). Empty for name matches. + /// + public string Detail { get; init; } = string.Empty; + + public bool HasDetail => Detail.Length > 0; +} diff --git a/GsaEditor/Views/MainWindow.axaml b/GsaEditor/Views/MainWindow.axaml index 935ba7d..80af9fe 100644 --- a/GsaEditor/Views/MainWindow.axaml +++ b/GsaEditor/Views/MainWindow.axaml @@ -93,10 +93,61 @@ + + + + + + + + + +