commit d6d621dc92b3083d8e47827baa0ccf59d5b0a4c4 Author: ItsTheSky Date: Thu Apr 9 16:56:58 2026 +0200 Add project restore files and NuGet cache for GsaViewer - Created project.nuget.cache to store NuGet package cache information. - Added project.packagespec.json to define project restore settings and dependencies. - Included rider.project.restore.info for Rider IDE integration. diff --git a/.idea/.idea.GsaViewer/.idea/.gitignore b/.idea/.idea.GsaViewer/.idea/.gitignore new file mode 100644 index 0000000..6aefcdf --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.GsaViewer.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.GsaViewer/.idea/avalonia.xml b/.idea/.idea.GsaViewer/.idea/avalonia.xml new file mode 100644 index 0000000..fbbd3c3 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/avalonia.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/copilot.data.migration.agent.xml b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/copilot.data.migration.ask.xml b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/copilot.data.migration.ask2agent.xml b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/copilot.data.migration.edit.xml b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/encodings.xml b/.idea/.idea.GsaViewer/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/indexLayout.xml b/.idea/.idea.GsaViewer/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml b/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..368f71c --- /dev/null +++ b/.idea/.idea.GsaViewer/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..a400aa8 --- /dev/null +++ b/App.axaml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..73e8b44 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,47 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core; +using Avalonia.Data.Core.Plugins; +using System.Linq; +using Avalonia.Markup.Xaml; +using GsaViewer.ViewModels; +using GsaViewer.Views; + +namespace GsaViewer; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins + DisableAvaloniaDataAnnotationValidation(); + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } + + private void DisableAvaloniaDataAnnotationValidation() + { + // Get an array of plugins to remove + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + // remove each entry found + foreach (var plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } +} \ No newline at end of file diff --git a/Assets/avalonia-logo.ico b/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/Assets/avalonia-logo.ico differ diff --git a/GsaEditor.Core/Compression/ZlibHelper.cs b/GsaEditor.Core/Compression/ZlibHelper.cs new file mode 100644 index 0000000..c3bf0a4 --- /dev/null +++ b/GsaEditor.Core/Compression/ZlibHelper.cs @@ -0,0 +1,88 @@ +using System.IO.Compression; + +namespace GsaEditor.Core.Compression; + +/// +/// Provides zlib compression and decompression utilities for GSA archive entries. +/// The GSA format stores zlib-wrapped data: 2-byte header (CMF+FLG) + deflate stream + 4-byte Adler-32 checksum. +/// This helper skips the 2-byte zlib header and uses for the core deflate data. +/// +public static class ZlibHelper +{ + /// + /// Decompresses zlib-wrapped data into a byte array of the specified original size. + /// Skips the 2-byte zlib header before feeding data to . + /// + /// The zlib-wrapped compressed data (header + deflate + checksum). + /// The expected decompressed size in bytes. + /// The decompressed byte array. + /// Thrown when decompression fails or data is corrupt. + public static byte[] Decompress(byte[] compressedData, uint originalLength) + { + if (compressedData.Length < 2) + throw new InvalidDataException("Compressed data is too short to contain a zlib header."); + + // Skip the 2-byte zlib header (typically 0x78 0x9C for default compression) + using var ms = new MemoryStream(compressedData, 2, compressedData.Length - 2); + using var deflate = new DeflateStream(ms, CompressionMode.Decompress); + + var result = new byte[originalLength]; + int totalRead = 0; + while (totalRead < result.Length) + { + int bytesRead = deflate.Read(result, totalRead, result.Length - totalRead); + if (bytesRead == 0) + break; + totalRead += bytesRead; + } + + return result; + } + + /// + /// Compresses data using zlib format: 2-byte header + deflate stream + 4-byte Adler-32 checksum. + /// + /// The uncompressed data to compress. + /// The zlib-wrapped compressed byte array. + public static byte[] Compress(byte[] data) + { + using var output = new MemoryStream(); + + // Write zlib header: CMF=0x78 (deflate method, 32K window), FLG=0x9C (default level, valid check bits) + output.WriteByte(0x78); + output.WriteByte(0x9C); + + using (var deflate = new DeflateStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + deflate.Write(data, 0, data.Length); + } + + // Write Adler-32 checksum in big-endian byte order + uint adler = ComputeAdler32(data); + output.WriteByte((byte)(adler >> 24)); + output.WriteByte((byte)(adler >> 16)); + output.WriteByte((byte)(adler >> 8)); + output.WriteByte((byte)adler); + + return output.ToArray(); + } + + /// + /// Computes the Adler-32 checksum of the given data. + /// + /// The data to checksum. + /// The 32-bit Adler-32 checksum value. + private static uint ComputeAdler32(byte[] data) + { + const uint MOD_ADLER = 65521; + uint a = 1, b = 0; + + for (int i = 0; i < data.Length; i++) + { + a = (a + data[i]) % MOD_ADLER; + b = (b + a) % MOD_ADLER; + } + + return (b << 16) | a; + } +} diff --git a/GsaEditor.Core/GsaEditor.Core.csproj b/GsaEditor.Core/GsaEditor.Core.csproj new file mode 100644 index 0000000..f271bc5 --- /dev/null +++ b/GsaEditor.Core/GsaEditor.Core.csproj @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + true + + diff --git a/GsaEditor.Core/IO/GsaIndexReader.cs b/GsaEditor.Core/IO/GsaIndexReader.cs new file mode 100644 index 0000000..5937e0b --- /dev/null +++ b/GsaEditor.Core/IO/GsaIndexReader.cs @@ -0,0 +1,70 @@ +using System.Xml.Linq; + +namespace GsaEditor.Core.IO; + +/// +/// Represents a single entry parsed from a .idx NML index file. +/// +public class GsaIndexEntry +{ + /// + /// The file alias (relative path) of the entry. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Compression method: 0 = raw, 1 = zlib. + /// + public int Method { get; set; } + + /// + /// Original (decompressed) size in bytes. + /// + public uint Length { get; set; } + + /// + /// Compressed size in bytes. + /// + public uint CompressedLength { get; set; } + + /// + /// Byte offset of the data block within the archive. + /// + public long Offset { get; set; } +} + +/// +/// Parses .idx NML index files (XML-compatible format) into a list of . +/// +public static class GsaIndexReader +{ + /// + /// Reads index entries from the specified .idx file. + /// + /// The path to the .idx file. + /// A list of parsed index entries. + public static List Read(string filePath) + { + var doc = XDocument.Load(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")) + { + var entry = new GsaIndexEntry + { + 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 + }; + entries.Add(entry); + } + + return entries; + } +} diff --git a/GsaEditor.Core/IO/GsaIndexWriter.cs b/GsaEditor.Core/IO/GsaIndexWriter.cs new file mode 100644 index 0000000..f462cae --- /dev/null +++ b/GsaEditor.Core/IO/GsaIndexWriter.cs @@ -0,0 +1,36 @@ +using System.Xml.Linq; +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. +/// +public static class GsaIndexWriter +{ + /// + /// Writes the index file for the given archive to the specified path. + /// + /// 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) + ) + ) + ) + ); + + doc.Save(filePath); + } +} diff --git a/GsaEditor.Core/IO/GsaReader.cs b/GsaEditor.Core/IO/GsaReader.cs new file mode 100644 index 0000000..ff29c6d --- /dev/null +++ b/GsaEditor.Core/IO/GsaReader.cs @@ -0,0 +1,163 @@ +using System.Text; +using GsaEditor.Core.Models; + +namespace GsaEditor.Core.IO; + +/// +/// Reads a .gsa binary archive from a stream or file and produces a . +/// Supports both NARC (legacy) and NARD (enhanced with padding) format variants. +/// +public static class GsaReader +{ + /// Magic word for the NARC (legacy) format. + private const uint MAGIC_NARC = 0x4E415243; + + /// Magic word for the NARD (enhanced) format. + private const uint MAGIC_NARD = 0x4E415244; + + /// Maximum allowed alias length in characters. + private const int MAX_ALIAS_LENGTH = 511; + + /// + /// Reads a GSA archive from the specified file path. + /// + /// The path to the .gsa archive file. + /// A fully populated . + /// Thrown if the file has an invalid magic word. + public static GsaArchive Read(string filePath) + { + using var stream = File.OpenRead(filePath); + return Read(stream); + } + + /// + /// Reads a GSA archive from a stream by performing a sequential scan of all entries. + /// + /// A readable, seekable stream positioned at the start of the archive. + /// A fully populated . + /// Thrown if the stream has an invalid magic word. + public static GsaArchive Read(Stream stream) + { + using var reader = new BinaryReader(stream, Encoding.ASCII, leaveOpen: true); + var archive = new GsaArchive(); + + // Read global header + uint magic = reader.ReadUInt32(); + if (magic == MAGIC_NARC) + { + archive.Format = GsaFormat.NARC; + } + else if (magic == MAGIC_NARD) + { + archive.Format = GsaFormat.NARD; + archive.OffsetPadding = reader.ReadInt32(); + archive.SizePadding = reader.ReadInt32(); + } + else + { + throw new InvalidDataException( + $"Invalid GSA magic word: 0x{magic:X8}. Expected NARC (0x{MAGIC_NARC:X8}) or NARD (0x{MAGIC_NARD:X8})."); + } + + // Sequentially read all entries until EOF + while (stream.Position < stream.Length) + { + var entry = ReadEntry(reader, archive); + if (entry == null) + break; + archive.Entries.Add(entry); + } + + return archive; + } + + /// + /// Reads a single file entry from the current stream position. + /// + /// The binary reader positioned at the start of an entry. + /// The parent archive (used for NARD padding alignment). + /// A populated , or null if no more entries can be read. + private static GsaEntry? ReadEntry(BinaryReader reader, GsaArchive archive) + { + var stream = reader.BaseStream; + + if (stream.Position >= stream.Length) + return null; + + // NARD: align stream position before reading entry header + if (archive.Format == GsaFormat.NARD && archive.OffsetPadding > 0) + AlignStream(stream, archive.OffsetPadding); + + // Check if enough bytes remain for the alias length field + if (stream.Length - stream.Position < 4) + return null; + + // Read alias + uint aliasLength = reader.ReadUInt32(); + if (aliasLength == 0 || aliasLength > MAX_ALIAS_LENGTH) + return null; + + if (stream.Length - stream.Position < aliasLength) + return null; + + byte[] aliasBytes = reader.ReadBytes((int)aliasLength); + string alias = Encoding.ASCII.GetString(aliasBytes); + + // Read compression flag (bit 0: 1=compressed, 0=raw) + byte compressionByte = reader.ReadByte(); + bool isCompressed = (compressionByte & 0x01) != 0; + + // Read original (decompressed) length + uint originalLength = reader.ReadUInt32(); + + // Read compressed length (only present if compressed) + uint compressedLength; + if (isCompressed) + { + compressedLength = reader.ReadUInt32(); + } + else + { + compressedLength = originalLength; + } + + // NARD: align stream position before reading the data block + if (archive.Format == GsaFormat.NARD && archive.OffsetPadding > 0) + AlignStream(stream, archive.OffsetPadding); + + // Read raw data + long dataOffset = stream.Position; + uint dataSize = isCompressed ? compressedLength : originalLength; + + if (stream.Length - stream.Position < dataSize) + return null; + + byte[] rawData = reader.ReadBytes((int)dataSize); + + return new GsaEntry + { + Alias = alias, + IsCompressed = isCompressed, + OriginalLength = originalLength, + CompressedLength = compressedLength, + RawData = rawData, + DataOffset = dataOffset + }; + } + + /// + /// Advances the stream position to the next aligned boundary. + /// + /// The stream to align. + /// The alignment boundary in bytes. + private static void AlignStream(Stream stream, int alignment) + { + if (alignment <= 1) return; + long pos = stream.Position; + long remainder = pos % alignment; + if (remainder != 0) + { + stream.Position = pos + (alignment - remainder); + } + } +} diff --git a/GsaEditor.Core/IO/GsaWriter.cs b/GsaEditor.Core/IO/GsaWriter.cs new file mode 100644 index 0000000..333474c --- /dev/null +++ b/GsaEditor.Core/IO/GsaWriter.cs @@ -0,0 +1,114 @@ +using System.Text; +using GsaEditor.Core.Models; + +namespace GsaEditor.Core.IO; + +/// +/// Writes a to a binary .gsa stream or file. +/// Supports both NARC (legacy) and NARD (enhanced with padding) format variants. +/// +public static class GsaWriter +{ + /// Magic word for the NARC (legacy) format. + private const uint MAGIC_NARC = 0x4E415243; + + /// Magic word for the NARD (enhanced) format. + private const uint MAGIC_NARD = 0x4E415244; + + /// + /// Writes the archive to the specified file path. + /// + /// The archive to write. + /// The output file path. + public static void Write(GsaArchive archive, string filePath) + { + using var stream = File.Create(filePath); + Write(archive, stream); + } + + /// + /// Writes the archive to the given stream. + /// Each entry's is updated to reflect the new position in the output. + /// + /// The archive to write. + /// A writable stream. + public static void Write(GsaArchive archive, Stream stream) + { + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + + // Write global header + if (archive.Format == GsaFormat.NARC) + { + writer.Write(MAGIC_NARC); + } + else + { + writer.Write(MAGIC_NARD); + writer.Write(archive.OffsetPadding); + writer.Write(archive.SizePadding); + } + + // Write each entry sequentially + foreach (var entry in archive.Entries) + { + WriteEntry(writer, entry, archive); + } + } + + /// + /// Writes a single file entry at the current stream position. + /// + /// The binary writer. + /// The entry to write. + /// The parent archive (used for NARD padding alignment). + private static void WriteEntry(BinaryWriter writer, GsaEntry entry, GsaArchive archive) + { + // NARD: align stream position before writing entry header + if (archive.Format == GsaFormat.NARD && archive.OffsetPadding > 0) + WritePadding(writer, archive.OffsetPadding); + + // Write alias + byte[] aliasBytes = Encoding.ASCII.GetBytes(entry.Alias); + writer.Write((uint)aliasBytes.Length); + writer.Write(aliasBytes); + + // Write compression flag + writer.Write((byte)(entry.IsCompressed ? 1 : 0)); + + // Write original (decompressed) length + writer.Write(entry.OriginalLength); + + // Write compressed length (only if compressed) + if (entry.IsCompressed) + { + writer.Write(entry.CompressedLength); + } + + // NARD: align stream position before writing the data block + if (archive.Format == GsaFormat.NARD && archive.OffsetPadding > 0) + WritePadding(writer, archive.OffsetPadding); + + // Update the entry's data offset to reflect the new position + entry.DataOffset = writer.BaseStream.Position; + + // Write raw data + writer.Write(entry.RawData); + } + + /// + /// Writes zero-bytes to pad the stream to the next aligned boundary. + /// + /// The binary writer. + /// The alignment boundary in bytes. + private static void WritePadding(BinaryWriter writer, int alignment) + { + if (alignment <= 1) return; + long pos = writer.BaseStream.Position; + long remainder = pos % alignment; + if (remainder != 0) + { + int paddingBytes = (int)(alignment - remainder); + writer.Write(new byte[paddingBytes]); + } + } +} diff --git a/GsaEditor.Core/Models/GsaArchive.cs b/GsaEditor.Core/Models/GsaArchive.cs new file mode 100644 index 0000000..c6652e4 --- /dev/null +++ b/GsaEditor.Core/Models/GsaArchive.cs @@ -0,0 +1,27 @@ +namespace GsaEditor.Core.Models; + +/// +/// Represents a loaded GSA archive with its global header information and file entries. +/// +public class GsaArchive +{ + /// + /// The format variant of this archive (NARC or NARD). + /// + public GsaFormat Format { get; set; } = GsaFormat.NARC; + + /// + /// (NARD only) Byte alignment for data offsets. Zero or unused for NARC. + /// + public int OffsetPadding { get; set; } + + /// + /// (NARD only) Byte alignment for data sizes. Zero or unused for NARC. + /// + public int SizePadding { get; set; } + + /// + /// The ordered list of file entries in the archive. + /// + public List Entries { get; set; } = new(); +} diff --git a/GsaEditor.Core/Models/GsaEntry.cs b/GsaEditor.Core/Models/GsaEntry.cs new file mode 100644 index 0000000..180e570 --- /dev/null +++ b/GsaEditor.Core/Models/GsaEntry.cs @@ -0,0 +1,77 @@ +using GsaEditor.Core.Compression; + +namespace GsaEditor.Core.Models; + +/// +/// Represents a single file entry within a GSA archive. +/// +public class GsaEntry +{ + /// + /// Relative path (alias) of the file within the archive, using '/' as separator. + /// Maximum 511 characters. + /// + public string Alias { get; set; } = string.Empty; + + /// + /// Whether this entry's data is zlib-compressed. + /// + public bool IsCompressed { get; set; } + + /// + /// Original (decompressed) size of the file in bytes. + /// + public uint OriginalLength { get; set; } + + /// + /// Compressed size of the file in bytes. Equal to if not compressed. + /// + public uint CompressedLength { get; set; } + + /// + /// Raw data as stored in the archive (compressed bytes if is true). + /// + public byte[] RawData { get; set; } = Array.Empty(); + + /// + /// Byte offset of this entry's data block within the archive file. + /// + public long DataOffset { get; set; } + + /// + /// Returns the decompressed data for this entry. + /// If the entry is not compressed, returns directly. + /// + /// The decompressed file content. + public byte[] GetDecompressedData() + { + if (!IsCompressed) + return RawData; + + return ZlibHelper.Decompress(RawData, OriginalLength); + } + + /// + /// Sets the entry data from uncompressed bytes, optionally compressing with zlib. + /// Updates , , + /// , and . + /// + /// The uncompressed file content. + /// Whether to compress the data with zlib. + public void SetData(byte[] uncompressedData, bool compress) + { + OriginalLength = (uint)uncompressedData.Length; + IsCompressed = compress; + + if (compress) + { + RawData = ZlibHelper.Compress(uncompressedData); + CompressedLength = (uint)RawData.Length; + } + else + { + RawData = uncompressedData; + CompressedLength = OriginalLength; + } + } +} diff --git a/GsaEditor.Core/Models/GsaFormat.cs b/GsaEditor.Core/Models/GsaFormat.cs new file mode 100644 index 0000000..9bdcf83 --- /dev/null +++ b/GsaEditor.Core/Models/GsaFormat.cs @@ -0,0 +1,18 @@ +namespace GsaEditor.Core.Models; + +/// +/// Identifies the archive format variant. +/// +public enum GsaFormat +{ + /// + /// Legacy format with no padding support. Magic word = 0x4E415243 ("NARC"). + /// + NARC, + + /// + /// Enhanced format with offset and size padding for console DVD alignment. + /// Magic word = 0x4E415244 ("NARD"). + /// + NARD +} diff --git a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.deps.json b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.deps.json new file mode 100644 index 0000000..2c82212 --- /dev/null +++ b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.deps.json @@ -0,0 +1,23 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v8.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v8.0": { + "GsaEditor.Core/1.0.0": { + "runtime": { + "GsaEditor.Core.dll": {} + } + } + } + }, + "libraries": { + "GsaEditor.Core/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.dll b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.dll new file mode 100644 index 0000000..4b60a8d Binary files /dev/null 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 new file mode 100644 index 0000000..5b9285b Binary files /dev/null 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 new file mode 100644 index 0000000..79f2155 --- /dev/null +++ b/GsaEditor.Core/bin/Debug/net8.0/GsaEditor.Core.xml @@ -0,0 +1,276 @@ + + + + GsaEditor.Core + + + + + Provides zlib compression and decompression utilities for GSA archive entries. + The GSA format stores zlib-wrapped data: 2-byte header (CMF+FLG) + deflate stream + 4-byte Adler-32 checksum. + This helper skips the 2-byte zlib header and uses for the core deflate data. + + + + + Decompresses zlib-wrapped data into a byte array of the specified original size. + Skips the 2-byte zlib header before feeding data to . + + The zlib-wrapped compressed data (header + deflate + checksum). + The expected decompressed size in bytes. + The decompressed byte array. + Thrown when decompression fails or data is corrupt. + + + + Compresses data using zlib format: 2-byte header + deflate stream + 4-byte Adler-32 checksum. + + The uncompressed data to compress. + The zlib-wrapped compressed byte array. + + + + Computes the Adler-32 checksum of the given data. + + The data to checksum. + The 32-bit Adler-32 checksum value. + + + + Represents a single entry parsed from a .idx NML index file. + + + + + The file alias (relative path) of the entry. + + + + + Compression method: 0 = raw, 1 = zlib. + + + + + Original (decompressed) size in bytes. + + + + + Compressed size in bytes. + + + + + Byte offset of the data block within the archive. + + + + + Parses .idx NML index files (XML-compatible format) into a list of . + + + + + Reads index entries from the specified .idx file. + + The path to the .idx file. + A list of parsed index entries. + + + + 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. + + + + + Writes the index file for the given archive to the specified path. + + The archive whose entries will be indexed. + The output .idx file path. + + + + Reads a .gsa binary archive from a stream or file and produces a . + Supports both NARC (legacy) and NARD (enhanced with padding) format variants. + + + + Magic word for the NARC (legacy) format. + + + Magic word for the NARD (enhanced) format. + + + Maximum allowed alias length in characters. + + + + Reads a GSA archive from the specified file path. + + The path to the .gsa archive file. + A fully populated . + Thrown if the file has an invalid magic word. + + + + Reads a GSA archive from a stream by performing a sequential scan of all entries. + + A readable, seekable stream positioned at the start of the archive. + A fully populated . + Thrown if the stream has an invalid magic word. + + + + Reads a single file entry from the current stream position. + + The binary reader positioned at the start of an entry. + The parent archive (used for NARD padding alignment). + A populated , or null if no more entries can be read. + + + + Advances the stream position to the next aligned boundary. + + The stream to align. + The alignment boundary in bytes. + + + + Writes a to a binary .gsa stream or file. + Supports both NARC (legacy) and NARD (enhanced with padding) format variants. + + + + Magic word for the NARC (legacy) format. + + + Magic word for the NARD (enhanced) format. + + + + Writes the archive to the specified file path. + + The archive to write. + The output file path. + + + + Writes the archive to the given stream. + Each entry's is updated to reflect the new position in the output. + + The archive to write. + A writable stream. + + + + Writes a single file entry at the current stream position. + + The binary writer. + The entry to write. + The parent archive (used for NARD padding alignment). + + + + Writes zero-bytes to pad the stream to the next aligned boundary. + + The binary writer. + The alignment boundary in bytes. + + + + Represents a loaded GSA archive with its global header information and file entries. + + + + + The format variant of this archive (NARC or NARD). + + + + + (NARD only) Byte alignment for data offsets. Zero or unused for NARC. + + + + + (NARD only) Byte alignment for data sizes. Zero or unused for NARC. + + + + + The ordered list of file entries in the archive. + + + + + Represents a single file entry within a GSA archive. + + + + + Relative path (alias) of the file within the archive, using '/' as separator. + Maximum 511 characters. + + + + + Whether this entry's data is zlib-compressed. + + + + + Original (decompressed) size of the file in bytes. + + + + + Compressed size of the file in bytes. Equal to if not compressed. + + + + + Raw data as stored in the archive (compressed bytes if is true). + + + + + Byte offset of this entry's data block within the archive file. + + + + + Returns the decompressed data for this entry. + If the entry is not compressed, returns directly. + + The decompressed file content. + + + + Sets the entry data from uncompressed bytes, optionally compressing with zlib. + Updates , , + , and . + + The uncompressed file content. + Whether to compress the data with zlib. + + + + Identifies the archive format variant. + + + + + Legacy format with no padding support. Magic word = 0x4E415243 ("NARC"). + + + + + Enhanced format with offset and size padding for console DVD alignment. + Magic word = 0x4E415244 ("NARD"). + + + + diff --git a/GsaEditor.Core/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs b/GsaEditor.Core/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs new file mode 100644 index 0000000..2217181 --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs @@ -0,0 +1,4 @@ +// +using System; +using System.Reflection; +[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")] diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs new file mode 100644 index 0000000..dfdd656 --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfo.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +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")] +[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. + diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache new file mode 100644 index 0000000..222b3db --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.AssemblyInfoInputs.cache @@ -0,0 +1 @@ +68387796343863a0e5c11cd0f612a1b364816551cf5816c6d556d410439a3710 diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.GeneratedMSBuildEditorConfig.editorconfig b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.GeneratedMSBuildEditorConfig.editorconfig new file mode 100644 index 0000000..7f5e757 --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.GeneratedMSBuildEditorConfig.editorconfig @@ -0,0 +1,15 @@ +is_global = true +build_property.TargetFramework = net8.0 +build_property.TargetPlatformMinVersion = +build_property.UsingMicrosoftNETSdkWeb = +build_property.ProjectTypeGuids = +build_property.InvariantGlobalization = +build_property.PlatformNeutralAssembly = +build_property.EnforceExtendedAnalyzerRules = +build_property._SupportedPlatformList = Linux,macOS,Windows +build_property.RootNamespace = GsaEditor.Core +build_property.ProjectDir = C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\ +build_property.EnableComHosting = +build_property.EnableGeneratedComInterfaceComImportInterop = +build_property.EffectiveAnalysisLevelStyle = 8.0 +build_property.EnableCodeStyleSeverity = diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.GlobalUsings.g.cs b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.GlobalUsings.g.cs new file mode 100644 index 0000000..8578f3d --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.GlobalUsings.g.cs @@ -0,0 +1,8 @@ +// +global using global::System; +global using global::System.Collections.Generic; +global using global::System.IO; +global using global::System.Linq; +global using global::System.Net.Http; +global using global::System.Threading; +global using global::System.Threading.Tasks; diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.assets.cache b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.assets.cache new file mode 100644 index 0000000..2f4d0cb Binary files /dev/null and b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.assets.cache differ diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.csproj.CoreCompileInputs.cache b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.csproj.CoreCompileInputs.cache new file mode 100644 index 0000000..9e76f5e --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.csproj.CoreCompileInputs.cache @@ -0,0 +1 @@ +f57ab49a8539dcd5eaae9903c06c691e87a9ead2bd6a913638151d11a0b5901a diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.csproj.FileListAbsolute.txt b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.csproj.FileListAbsolute.txt new file mode 100644 index 0000000..6466dd0 --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.csproj.FileListAbsolute.txt @@ -0,0 +1,13 @@ +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\bin\Debug\net8.0\GsaEditor.Core.deps.json +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\bin\Debug\net8.0\GsaEditor.Core.dll +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\bin\Debug\net8.0\GsaEditor.Core.pdb +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\bin\Debug\net8.0\GsaEditor.Core.xml +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.GeneratedMSBuildEditorConfig.editorconfig +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.AssemblyInfoInputs.cache +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.AssemblyInfo.cs +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.csproj.CoreCompileInputs.cache +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.dll +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\refint\GsaEditor.Core.dll +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.xml +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\GsaEditor.Core.pdb +C:\Users\simulateur\Desktop\GsaViewer\GsaEditor.Core\obj\Debug\net8.0\ref\GsaEditor.Core.dll diff --git a/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.dll b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.dll new file mode 100644 index 0000000..4b60a8d Binary files /dev/null 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 new file mode 100644 index 0000000..5b9285b Binary files /dev/null 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 new file mode 100644 index 0000000..79f2155 --- /dev/null +++ b/GsaEditor.Core/obj/Debug/net8.0/GsaEditor.Core.xml @@ -0,0 +1,276 @@ + + + + GsaEditor.Core + + + + + Provides zlib compression and decompression utilities for GSA archive entries. + The GSA format stores zlib-wrapped data: 2-byte header (CMF+FLG) + deflate stream + 4-byte Adler-32 checksum. + This helper skips the 2-byte zlib header and uses for the core deflate data. + + + + + Decompresses zlib-wrapped data into a byte array of the specified original size. + Skips the 2-byte zlib header before feeding data to . + + The zlib-wrapped compressed data (header + deflate + checksum). + The expected decompressed size in bytes. + The decompressed byte array. + Thrown when decompression fails or data is corrupt. + + + + Compresses data using zlib format: 2-byte header + deflate stream + 4-byte Adler-32 checksum. + + The uncompressed data to compress. + The zlib-wrapped compressed byte array. + + + + Computes the Adler-32 checksum of the given data. + + The data to checksum. + The 32-bit Adler-32 checksum value. + + + + Represents a single entry parsed from a .idx NML index file. + + + + + The file alias (relative path) of the entry. + + + + + Compression method: 0 = raw, 1 = zlib. + + + + + Original (decompressed) size in bytes. + + + + + Compressed size in bytes. + + + + + Byte offset of the data block within the archive. + + + + + Parses .idx NML index files (XML-compatible format) into a list of . + + + + + Reads index entries from the specified .idx file. + + The path to the .idx file. + A list of parsed index entries. + + + + 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. + + + + + Writes the index file for the given archive to the specified path. + + The archive whose entries will be indexed. + The output .idx file path. + + + + Reads a .gsa binary archive from a stream or file and produces a . + Supports both NARC (legacy) and NARD (enhanced with padding) format variants. + + + + Magic word for the NARC (legacy) format. + + + Magic word for the NARD (enhanced) format. + + + Maximum allowed alias length in characters. + + + + Reads a GSA archive from the specified file path. + + The path to the .gsa archive file. + A fully populated . + Thrown if the file has an invalid magic word. + + + + Reads a GSA archive from a stream by performing a sequential scan of all entries. + + A readable, seekable stream positioned at the start of the archive. + A fully populated . + Thrown if the stream has an invalid magic word. + + + + Reads a single file entry from the current stream position. + + The binary reader positioned at the start of an entry. + The parent archive (used for NARD padding alignment). + A populated , or null if no more entries can be read. + + + + Advances the stream position to the next aligned boundary. + + The stream to align. + The alignment boundary in bytes. + + + + Writes a to a binary .gsa stream or file. + Supports both NARC (legacy) and NARD (enhanced with padding) format variants. + + + + Magic word for the NARC (legacy) format. + + + Magic word for the NARD (enhanced) format. + + + + Writes the archive to the specified file path. + + The archive to write. + The output file path. + + + + Writes the archive to the given stream. + Each entry's is updated to reflect the new position in the output. + + The archive to write. + A writable stream. + + + + Writes a single file entry at the current stream position. + + The binary writer. + The entry to write. + The parent archive (used for NARD padding alignment). + + + + Writes zero-bytes to pad the stream to the next aligned boundary. + + The binary writer. + The alignment boundary in bytes. + + + + Represents a loaded GSA archive with its global header information and file entries. + + + + + The format variant of this archive (NARC or NARD). + + + + + (NARD only) Byte alignment for data offsets. Zero or unused for NARC. + + + + + (NARD only) Byte alignment for data sizes. Zero or unused for NARC. + + + + + The ordered list of file entries in the archive. + + + + + Represents a single file entry within a GSA archive. + + + + + Relative path (alias) of the file within the archive, using '/' as separator. + Maximum 511 characters. + + + + + Whether this entry's data is zlib-compressed. + + + + + Original (decompressed) size of the file in bytes. + + + + + Compressed size of the file in bytes. Equal to if not compressed. + + + + + Raw data as stored in the archive (compressed bytes if is true). + + + + + Byte offset of this entry's data block within the archive file. + + + + + Returns the decompressed data for this entry. + If the entry is not compressed, returns directly. + + The decompressed file content. + + + + Sets the entry data from uncompressed bytes, optionally compressing with zlib. + Updates , , + , and . + + The uncompressed file content. + Whether to compress the data with zlib. + + + + Identifies the archive format variant. + + + + + Legacy format with no padding support. Magic word = 0x4E415243 ("NARC"). + + + + + Enhanced format with offset and size padding for console DVD alignment. + Magic word = 0x4E415244 ("NARD"). + + + + diff --git a/GsaEditor.Core/obj/Debug/net8.0/ref/GsaEditor.Core.dll b/GsaEditor.Core/obj/Debug/net8.0/ref/GsaEditor.Core.dll new file mode 100644 index 0000000..4c7eb2e Binary files /dev/null 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 new file mode 100644 index 0000000..4c7eb2e Binary files /dev/null and b/GsaEditor.Core/obj/Debug/net8.0/refint/GsaEditor.Core.dll differ diff --git a/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.dgspec.json b/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.dgspec.json new file mode 100644 index 0000000..8a01775 --- /dev/null +++ b/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.dgspec.json @@ -0,0 +1,81 @@ +{ + "format": 1, + "restore": { + "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj": {} + }, + "projects": { + "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj", + "projectName": "GsaEditor.Core", + "projectPath": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj", + "packagesPath": "C:\\Users\\simulateur\\.nuget\\packages\\", + "outputPath": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\obj\\", + "projectStyle": "PackageReference", + "configFilePaths": [ + "C:\\Users\\simulateur\\AppData\\Roaming\\NuGet\\NuGet.Config" + ], + "originalTargetFrameworks": [ + "net8.0" + ], + "sources": { + "https://api.nuget.org/v3/index.json": {} + }, + "frameworks": { + "net8.0": { + "targetAlias": "net8.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + }, + "SdkAnalysisLevel": "9.0.300" + }, + "frameworks": { + "net8.0": { + "targetAlias": "net8.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "downloadDependencies": [ + { + "name": "Microsoft.AspNetCore.App.Ref", + "version": "[8.0.21, 8.0.21]" + }, + { + "name": "Microsoft.NETCore.App.Ref", + "version": "[8.0.21, 8.0.21]" + }, + { + "name": "Microsoft.WindowsDesktop.App.Ref", + "version": "[8.0.21, 8.0.21]" + } + ], + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.306/PortableRuntimeIdentifierGraph.json" + } + } + } + } +} \ No newline at end of file diff --git a/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.g.props b/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.g.props new file mode 100644 index 0000000..90e8498 --- /dev/null +++ b/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.g.props @@ -0,0 +1,15 @@ + + + + True + NuGet + $(MSBuildThisFileDirectory)project.assets.json + $(UserProfile)\.nuget\packages\ + C:\Users\simulateur\.nuget\packages\ + PackageReference + 6.14.0 + + + + + \ No newline at end of file diff --git a/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.g.targets b/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.g.targets new file mode 100644 index 0000000..3dc06ef --- /dev/null +++ b/GsaEditor.Core/obj/GsaEditor.Core.csproj.nuget.g.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/GsaEditor.Core/obj/project.assets.json b/GsaEditor.Core/obj/project.assets.json new file mode 100644 index 0000000..bee0539 --- /dev/null +++ b/GsaEditor.Core/obj/project.assets.json @@ -0,0 +1,86 @@ +{ + "version": 3, + "targets": { + "net8.0": {} + }, + "libraries": {}, + "projectFileDependencyGroups": { + "net8.0": [] + }, + "packageFolders": { + "C:\\Users\\simulateur\\.nuget\\packages\\": {} + }, + "project": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj", + "projectName": "GsaEditor.Core", + "projectPath": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj", + "packagesPath": "C:\\Users\\simulateur\\.nuget\\packages\\", + "outputPath": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\obj\\", + "projectStyle": "PackageReference", + "configFilePaths": [ + "C:\\Users\\simulateur\\AppData\\Roaming\\NuGet\\NuGet.Config" + ], + "originalTargetFrameworks": [ + "net8.0" + ], + "sources": { + "https://api.nuget.org/v3/index.json": {} + }, + "frameworks": { + "net8.0": { + "targetAlias": "net8.0", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + }, + "SdkAnalysisLevel": "9.0.300" + }, + "frameworks": { + "net8.0": { + "targetAlias": "net8.0", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "downloadDependencies": [ + { + "name": "Microsoft.AspNetCore.App.Ref", + "version": "[8.0.21, 8.0.21]" + }, + { + "name": "Microsoft.NETCore.App.Ref", + "version": "[8.0.21, 8.0.21]" + }, + { + "name": "Microsoft.WindowsDesktop.App.Ref", + "version": "[8.0.21, 8.0.21]" + } + ], + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.306/PortableRuntimeIdentifierGraph.json" + } + } + } +} \ No newline at end of file diff --git a/GsaEditor.Core/obj/project.nuget.cache b/GsaEditor.Core/obj/project.nuget.cache new file mode 100644 index 0000000..34ba18a --- /dev/null +++ b/GsaEditor.Core/obj/project.nuget.cache @@ -0,0 +1,12 @@ +{ + "version": 2, + "dgSpecHash": "8Lf0I0TQO2o=", + "success": true, + "projectFilePath": "C:\\Users\\simulateur\\Desktop\\GsaViewer\\GsaEditor.Core\\GsaEditor.Core.csproj", + "expectedPackageFiles": [ + "C:\\Users\\simulateur\\.nuget\\packages\\microsoft.netcore.app.ref\\8.0.21\\microsoft.netcore.app.ref.8.0.21.nupkg.sha512", + "C:\\Users\\simulateur\\.nuget\\packages\\microsoft.windowsdesktop.app.ref\\8.0.21\\microsoft.windowsdesktop.app.ref.8.0.21.nupkg.sha512", + "C:\\Users\\simulateur\\.nuget\\packages\\microsoft.aspnetcore.app.ref\\8.0.21\\microsoft.aspnetcore.app.ref.8.0.21.nupkg.sha512" + ], + "logs": [] +} \ No newline at end of file diff --git a/GsaEditor.sln b/GsaEditor.sln new file mode 100644 index 0000000..439addd --- /dev/null +++ b/GsaEditor.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GsaEditor", "GsaEditor\GsaEditor.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GsaEditor.Core", "GsaEditor.Core\GsaEditor.Core.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/GsaEditor/App.axaml b/GsaEditor/App.axaml new file mode 100644 index 0000000..b28a7e1 --- /dev/null +++ b/GsaEditor/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/GsaEditor/App.axaml.cs b/GsaEditor/App.axaml.cs new file mode 100644 index 0000000..fa5f3eb --- /dev/null +++ b/GsaEditor/App.axaml.cs @@ -0,0 +1,35 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using GsaEditor.ViewModels; +using GsaEditor.Views; + +namespace GsaEditor; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + foreach (var plugin in dataValidationPluginsToRemove) + BindingPlugins.DataValidators.Remove(plugin); + + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel() + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/GsaEditor/GsaEditor.csproj b/GsaEditor/GsaEditor.csproj new file mode 100644 index 0000000..fd5035f --- /dev/null +++ b/GsaEditor/GsaEditor.csproj @@ -0,0 +1,31 @@ + + + WinExe + net8.0 + enable + enable + true + app.manifest + true + + + + + + + + + + + + + None + All + + + + + + + + diff --git a/GsaEditor/Helpers/Dialogs.cs b/GsaEditor/Helpers/Dialogs.cs new file mode 100644 index 0000000..e77500a --- /dev/null +++ b/GsaEditor/Helpers/Dialogs.cs @@ -0,0 +1,204 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace GsaEditor.Helpers; + +/// +/// Provides simple dialog utilities (confirm, message, input) using programmatically-created Avalonia windows. +/// No third-party controls or custom themes are used. +/// +public static class Dialogs +{ + /// + /// Shows a confirmation dialog with Yes/No buttons. + /// + /// The parent window. + /// Dialog title. + /// Message to display. + /// True if the user clicked Yes, false otherwise. + public static async Task ConfirmAsync(Window parent, string title, string message) + { + var result = false; + + var dialog = new Window + { + Title = title, + Width = 420, + Height = 170, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + ShowInTaskbar = false + }; + + var messageBlock = new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 15) + }; + + var yesButton = new Button + { + Content = "Yes", + Width = 80, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + yesButton.Click += (_, _) => + { + result = true; + dialog.Close(); + }; + + var noButton = new Button + { + Content = "No", + Width = 80, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + noButton.Click += (_, _) => + { + result = false; + dialog.Close(); + }; + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 10, + Children = { yesButton, noButton } + }; + + dialog.Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 5, + Children = { messageBlock, buttonPanel } + }; + + await dialog.ShowDialog(parent); + return result; + } + + /// + /// Shows a simple message dialog with an OK button. + /// + /// The parent window. + /// Dialog title. + /// Message to display. + public static async Task ShowMessageAsync(Window parent, string title, string message) + { + var dialog = new Window + { + Title = title, + Width = 420, + Height = 180, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + ShowInTaskbar = false + }; + + var messageBlock = new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 15) + }; + + var okButton = new Button + { + Content = "OK", + Width = 80, + HorizontalAlignment = HorizontalAlignment.Right, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + okButton.Click += (_, _) => dialog.Close(); + + dialog.Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 5, + Children = { messageBlock, okButton } + }; + + await dialog.ShowDialog(parent); + } + + /// + /// Shows an input dialog with a text box, OK and Cancel buttons. + /// + /// The parent window. + /// Dialog title. + /// Prompt text shown above the input. + /// Default value in the text box. + /// The entered string, or null if cancelled. + public static async Task InputAsync(Window parent, string title, string prompt, string defaultValue = "") + { + string? result = null; + + var dialog = new Window + { + Title = title, + Width = 450, + Height = 180, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + ShowInTaskbar = false + }; + + var promptBlock = new TextBlock + { + Text = prompt, + Margin = new Thickness(0, 0, 0, 5) + }; + + var textBox = new TextBox + { + Text = defaultValue, + Margin = new Thickness(0, 0, 0, 10) + }; + + var okButton = new Button + { + Content = "OK", + Width = 80, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + okButton.Click += (_, _) => + { + result = textBox.Text; + dialog.Close(); + }; + + var cancelButton = new Button + { + Content = "Cancel", + Width = 80, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + cancelButton.Click += (_, _) => + { + result = null; + dialog.Close(); + }; + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 10, + Children = { okButton, cancelButton } + }; + + dialog.Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 5, + Children = { promptBlock, textBox, buttonPanel } + }; + + await dialog.ShowDialog(parent); + return result; + } +} diff --git a/GsaEditor/Helpers/HexDumper.cs b/GsaEditor/Helpers/HexDumper.cs new file mode 100644 index 0000000..1724ebe --- /dev/null +++ b/GsaEditor/Helpers/HexDumper.cs @@ -0,0 +1,59 @@ +using System.Text; + +namespace GsaEditor.Helpers; + +/// +/// Produces a classic hex + ASCII dump string from a byte array. +/// +public static class HexDumper +{ + /// + /// Formats a byte array as a hex dump with offset, hex values, and printable ASCII characters. + /// + /// The data to dump. + /// Maximum number of bytes to include in the dump. + /// A formatted hex dump string. + public static string Dump(byte[] data, int maxBytes = 512) + { + var sb = new StringBuilder(); + int length = Math.Min(data.Length, maxBytes); + const int bytesPerLine = 16; + + for (int offset = 0; offset < length; offset += bytesPerLine) + { + // Offset column + sb.Append($"{offset:X8} "); + + int lineLen = Math.Min(bytesPerLine, length - offset); + + // Hex columns + for (int i = 0; i < bytesPerLine; i++) + { + if (i < lineLen) + sb.Append($"{data[offset + i]:X2} "); + else + sb.Append(" "); + + if (i == 7) sb.Append(' '); + } + + sb.Append(' '); + + // ASCII column + for (int i = 0; i < lineLen; i++) + { + byte b = data[offset + i]; + sb.Append(b >= 0x20 && b < 0x7F ? (char)b : '.'); + } + + sb.AppendLine(); + } + + if (data.Length > maxBytes) + { + sb.AppendLine($"... ({data.Length - maxBytes} more bytes)"); + } + + return sb.ToString(); + } +} diff --git a/GsaEditor/Program.cs b/GsaEditor/Program.cs new file mode 100644 index 0000000..84ff7b7 --- /dev/null +++ b/GsaEditor/Program.cs @@ -0,0 +1,17 @@ +using Avalonia; +using System; + +namespace GsaEditor; + +sealed class Program +{ + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs b/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs new file mode 100644 index 0000000..b68f02a --- /dev/null +++ b/GsaEditor/ViewModels/EntryTreeNodeViewModel.cs @@ -0,0 +1,40 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using GsaEditor.Core.Models; + +namespace GsaEditor.ViewModels; + +/// +/// Represents a node in the archive tree view. +/// Can be a folder (with children) or a leaf file node (with an associated ). +/// +public partial class EntryTreeNodeViewModel : ViewModelBase +{ + [ObservableProperty] + private string _name = string.Empty; + + /// + /// Full relative path of this node within the archive (using '/' separator). + /// + public string FullPath { get; set; } = string.Empty; + + /// + /// Whether this node represents a folder (true) or a file (false). + /// + public bool IsFolder { get; set; } + + /// + /// Whether this node represents a file. + /// + public bool IsFile => !IsFolder; + + /// + /// The archive entry this node represents. Null for folder nodes. + /// + public GsaEntry? Entry { get; set; } + + /// + /// Child nodes (sub-folders and files within this folder). + /// + public ObservableCollection Children { get; } = new(); +} diff --git a/GsaEditor/ViewModels/EntryViewModel.cs b/GsaEditor/ViewModels/EntryViewModel.cs new file mode 100644 index 0000000..d7118ea --- /dev/null +++ b/GsaEditor/ViewModels/EntryViewModel.cs @@ -0,0 +1,93 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using GsaEditor.Core.Models; + +namespace GsaEditor.ViewModels; + +/// +/// Wraps a for display in the details panel. +/// +public partial class EntryViewModel : ViewModelBase +{ + private readonly GsaEntry _entry; + + [ObservableProperty] + private string _alias = string.Empty; + + [ObservableProperty] + private bool _isCompressed; + + /// + /// Callback invoked when entry data is modified (compression toggle, alias change). + /// + public Action? OnModified { get; set; } + + public EntryViewModel(GsaEntry entry) + { + _entry = entry; + _alias = entry.Alias; + _isCompressed = entry.IsCompressed; + } + + /// + /// The underlying . + /// + public GsaEntry Entry => _entry; + + /// + /// Original (decompressed) file size in bytes. + /// + public uint OriginalSize => _entry.OriginalLength; + + /// + /// Compressed file size in bytes. + /// + public uint CompressedSize => _entry.CompressedLength; + + /// + /// Compression ratio as a percentage (0–100). Higher means more compression. + /// + public string CompressionRatio => OriginalSize > 0 + ? $"{(1.0 - (double)CompressedSize / OriginalSize) * 100.0:F1}%" + : "0.0%"; + + /// + /// Human-readable original size string. + /// + public string OriginalSizeText => FormatSize(OriginalSize); + + /// + /// Human-readable compressed size string. + /// + public string CompressedSizeText => FormatSize(CompressedSize); + + partial void OnAliasChanged(string value) + { + if (_entry.Alias != value) + { + _entry.Alias = value; + OnModified?.Invoke(); + } + } + + partial void OnIsCompressedChanged(bool value) + { + if (_entry.IsCompressed != value) + { + var decompressed = _entry.GetDecompressedData(); + _entry.SetData(decompressed, value); + OnPropertyChanged(nameof(OriginalSize)); + OnPropertyChanged(nameof(CompressedSize)); + OnPropertyChanged(nameof(CompressionRatio)); + OnPropertyChanged(nameof(OriginalSizeText)); + OnPropertyChanged(nameof(CompressedSizeText)); + OnModified?.Invoke(); + } + } + + private static string FormatSize(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } +} diff --git a/GsaEditor/ViewModels/MainWindowViewModel.cs b/GsaEditor/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..0ba3ee6 --- /dev/null +++ b/GsaEditor/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,726 @@ +using System.Collections.ObjectModel; +using System.Text; +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GsaEditor.Core.Compression; +using GsaEditor.Core.IO; +using GsaEditor.Core.Models; +using GsaEditor.Helpers; + +namespace GsaEditor.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".txt", ".nml", ".xml", ".json", ".csv", ".ini", ".cfg", ".log", ".nut" + }; + + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".bmp" + }; + + private static readonly HashSet UnsupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".tga", ".dds" + }; + + private static readonly FilePickerFileType GsaFileType = new("GSA Archives") + { + Patterns = new[] { "*.gsa" } + }; + + private static readonly FilePickerFileType AllFilesType = new("All Files") + { + Patterns = new[] { "*" } + }; + + private Window? _window; + private GsaArchive? _archive; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + [NotifyPropertyChangedFor(nameof(StatusArchivePath))] + [NotifyPropertyChangedFor(nameof(HasArchive))] + private string? _archivePath; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + private bool _isDirty; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSelectedFile))] + [NotifyPropertyChangedFor(nameof(HasSelectedFolder))] + [NotifyPropertyChangedFor(nameof(HasSelectedEntry))] + private EntryTreeNodeViewModel? _selectedNode; + + [ObservableProperty] + private EntryViewModel? _selectedEntry; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTextPreview))] + [NotifyPropertyChangedFor(nameof(IsImagePreview))] + [NotifyPropertyChangedFor(nameof(IsHexPreview))] + [NotifyPropertyChangedFor(nameof(HasPreview))] + private PreviewMode _previewMode = PreviewMode.None; + + [ObservableProperty] + private string? _previewText; + + [ObservableProperty] + private Bitmap? _previewImage; + + [ObservableProperty] + private string? _hexDumpText; + + [ObservableProperty] + private bool _isEditingText; + + public ObservableCollection TreeRoots { get; } = new(); + + // --- Derived properties --- + + public string Title => ArchivePath != null + ? $"GsaEditor - {Path.GetFileName(ArchivePath)}{(IsDirty ? " *" : "")}" + : "GsaEditor"; + + public bool HasArchive => _archive != null; + public bool HasSelectedEntry => SelectedNode?.Entry != null; + public bool HasSelectedFile => SelectedNode != null && !SelectedNode.IsFolder; + public bool HasSelectedFolder => SelectedNode != null && SelectedNode.IsFolder; + public bool IsTextPreview => PreviewMode == PreviewMode.Text; + public bool IsImagePreview => PreviewMode == PreviewMode.Image; + public bool IsHexPreview => PreviewMode == PreviewMode.HexDump; + public bool HasPreview => PreviewMode != PreviewMode.None; + + // --- Status bar --- + + public string StatusArchivePath => ArchivePath ?? "No archive loaded"; + + public string StatusEntryCount => _archive != null + ? $"{_archive.Entries.Count} entries" + : ""; + + public string StatusSizeInfo + { + get + { + if (_archive == null) return ""; + long totalOriginal = _archive.Entries.Sum(e => (long)e.OriginalLength); + long totalCompressed = _archive.Entries.Sum(e => (long)e.CompressedLength); + return $"Total: {FormatSize(totalOriginal)} / Compressed: {FormatSize(totalCompressed)}"; + } + } + + /// + /// Sets the parent window reference for dialog display. + /// + public void SetWindow(Window window) + { + _window = window; + } + + // ========================================================================= + // Selection changed + // ========================================================================= + + partial void OnSelectedNodeChanged(EntryTreeNodeViewModel? value) + { + IsEditingText = false; + + if (value?.Entry != null) + { + var entryVm = new EntryViewModel(value.Entry); + entryVm.OnModified = () => + { + IsDirty = true; + NotifyStatusChanged(); + }; + SelectedEntry = entryVm; + UpdatePreview(value.Entry); + } + else + { + SelectedEntry = null; + ClearPreview(); + } + } + + private void UpdatePreview(GsaEntry entry) + { + var ext = Path.GetExtension(entry.Alias).ToLowerInvariant(); + + if (TextExtensions.Contains(ext)) + { + PreviewMode = PreviewMode.Text; + var data = entry.GetDecompressedData(); + PreviewText = Encoding.UTF8.GetString(data); + PreviewImage?.Dispose(); + PreviewImage = null; + HexDumpText = null; + } + else if (ImageExtensions.Contains(ext)) + { + try + { + var data = entry.GetDecompressedData(); + using var ms = new MemoryStream(data); + PreviewImage?.Dispose(); + PreviewImage = new Bitmap(ms); + PreviewMode = PreviewMode.Image; + } + catch + { + PreviewImage = null; + PreviewMode = PreviewMode.HexDump; + HexDumpText = "Preview not available for this image."; + } + PreviewText = null; + } + else if (UnsupportedImageExtensions.Contains(ext)) + { + PreviewMode = PreviewMode.HexDump; + HexDumpText = $"Preview not available for {ext.ToUpperInvariant()} files."; + PreviewText = null; + PreviewImage?.Dispose(); + PreviewImage = null; + } + else + { + PreviewMode = PreviewMode.HexDump; + var data = entry.GetDecompressedData(); + HexDumpText = HexDumper.Dump(data, 512); + PreviewText = null; + PreviewImage?.Dispose(); + PreviewImage = null; + } + } + + private void ClearPreview() + { + PreviewMode = PreviewMode.None; + PreviewText = null; + PreviewImage?.Dispose(); + PreviewImage = null; + HexDumpText = null; + } + + // ========================================================================= + // File menu commands + // ========================================================================= + + [RelayCommand] + private async Task OpenArchive() + { + if (_window == null) return; + + if (IsDirty) + { + var confirmed = await Dialogs.ConfirmAsync(_window, "Unsaved Changes", + "You have unsaved changes. Open a new archive without saving?"); + if (!confirmed) return; + } + + var files = await _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open GSA Archive", + FileTypeFilter = new[] { GsaFileType, AllFilesType }, + AllowMultiple = false + }); + + if (files.Count == 0) return; + + var path = files[0].Path.LocalPath; + await LoadArchiveAsync(path); + } + + private async Task LoadArchiveAsync(string path) + { + IsLoading = true; + ClearPreview(); + SelectedEntry = null; + SelectedNode = null; + + try + { + var archive = await Task.Run(() => GsaReader.Read(path)); + _archive = archive; + ArchivePath = path; + IsDirty = false; + BuildTree(); + NotifyStatusChanged(); + } + catch (Exception ex) + { + if (_window != null) + await Dialogs.ShowMessageAsync(_window, "Error", $"Failed to open archive:\n{ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + [RelayCommand] + private async Task Save() + { + if (_archive == null || _window == null) return; + + if (string.IsNullOrEmpty(ArchivePath)) + { + await SaveAs(); + return; + } + + try + { + await Task.Run(() => GsaWriter.Write(_archive, ArchivePath!)); + + // Generate .idx alongside + var idxPath = Path.ChangeExtension(ArchivePath, ".idx"); + await Task.Run(() => GsaIndexWriter.Write(_archive, idxPath)); + + IsDirty = false; + } + catch (Exception ex) + { + await Dialogs.ShowMessageAsync(_window, "Error", $"Failed to save archive:\n{ex.Message}"); + } + } + + [RelayCommand] + private async Task SaveAs() + { + if (_archive == null || _window == null) return; + + var file = await _window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save GSA Archive As", + FileTypeChoices = new[] { GsaFileType }, + DefaultExtension = "gsa", + SuggestedFileName = ArchivePath != null ? Path.GetFileName(ArchivePath) : "archive.gsa" + }); + + if (file == null) return; + + var path = file.Path.LocalPath; + + try + { + await Task.Run(() => GsaWriter.Write(_archive, path)); + + var idxPath = Path.ChangeExtension(path, ".idx"); + await Task.Run(() => GsaIndexWriter.Write(_archive, idxPath)); + + ArchivePath = path; + IsDirty = false; + } + catch (Exception ex) + { + await Dialogs.ShowMessageAsync(_window, "Error", $"Failed to save archive:\n{ex.Message}"); + } + } + + [RelayCommand] + private async Task CloseArchive() + { + if (_window == null) return; + + if (IsDirty) + { + var confirmed = await Dialogs.ConfirmAsync(_window, "Unsaved Changes", + "You have unsaved changes. Close without saving?"); + if (!confirmed) return; + } + + _archive = null; + ArchivePath = null; + IsDirty = false; + TreeRoots.Clear(); + SelectedEntry = null; + SelectedNode = null; + ClearPreview(); + NotifyStatusChanged(); + } + + [RelayCommand] + private void Exit() + { + _window?.Close(); + } + + // ========================================================================= + // Edit menu commands + // ========================================================================= + + [RelayCommand] + private async Task AddFiles() + { + if (_archive == null || _window == null) return; + + var files = await _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Add Files to Archive", + AllowMultiple = true, + FileTypeFilter = new[] { AllFilesType } + }); + + if (files.Count == 0) return; + + string prefix = ""; + if (SelectedNode != null && SelectedNode.IsFolder) + { + prefix = SelectedNode.FullPath; + if (!prefix.EndsWith("/")) prefix += "/"; + } + + foreach (var file in files) + { + var filePath = file.Path.LocalPath; + var fileName = Path.GetFileName(filePath); + var alias = prefix + fileName; + + var data = await Task.Run(() => File.ReadAllBytes(filePath)); + var entry = new GsaEntry(); + entry.Alias = alias; + entry.SetData(data, compress: true); + + _archive.Entries.Add(entry); + } + + IsDirty = true; + BuildTree(); + NotifyStatusChanged(); + } + + [RelayCommand] + private async Task ExtractAll() + { + if (_archive == null || _window == null) return; + + var folders = await _window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Extraction Folder" + }); + + if (folders.Count == 0) return; + + var basePath = folders[0].Path.LocalPath; + + try + { + await Task.Run(() => + { + foreach (var entry in _archive.Entries) + { + var outputPath = Path.Combine(basePath, entry.Alias.Replace('/', Path.DirectorySeparatorChar)); + var dir = Path.GetDirectoryName(outputPath); + if (dir != null) Directory.CreateDirectory(dir); + + var data = entry.GetDecompressedData(); + File.WriteAllBytes(outputPath, data); + } + }); + + await Dialogs.ShowMessageAsync(_window, "Extract All", + $"Successfully extracted {_archive.Entries.Count} files to:\n{basePath}"); + } + catch (Exception ex) + { + await Dialogs.ShowMessageAsync(_window, "Error", $"Extraction failed:\n{ex.Message}"); + } + } + + [RelayCommand] + private async Task Repack() + { + if (_archive == null || _window == null) return; + + IsLoading = true; + try + { + await Task.Run(() => + { + foreach (var entry in _archive.Entries) + { + var decompressed = entry.GetDecompressedData(); + entry.SetData(decompressed, entry.IsCompressed); + } + }); + + IsDirty = true; + BuildTree(); + NotifyStatusChanged(); + + if (SelectedNode?.Entry != null) + { + var entryVm = new EntryViewModel(SelectedNode.Entry); + entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; + SelectedEntry = entryVm; + } + } + finally + { + IsLoading = false; + } + } + + // ========================================================================= + // Context menu commands + // ========================================================================= + + [RelayCommand] + private async Task ExtractEntry() + { + if (SelectedNode?.Entry == null || _window == null) return; + + var entry = SelectedNode.Entry; + var file = await _window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Extract File", + SuggestedFileName = Path.GetFileName(entry.Alias), + FileTypeChoices = new[] { AllFilesType } + }); + + if (file == null) return; + + try + { + var data = entry.GetDecompressedData(); + var path = file.Path.LocalPath; + await File.WriteAllBytesAsync(path, data); + } + catch (Exception ex) + { + await Dialogs.ShowMessageAsync(_window, "Error", $"Extraction failed:\n{ex.Message}"); + } + } + + [RelayCommand] + private async Task ReplaceEntry() + { + if (SelectedNode?.Entry == null || _archive == null || _window == null) return; + + var files = await _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Replace Entry With", + AllowMultiple = false, + FileTypeFilter = new[] { AllFilesType } + }); + + if (files.Count == 0) return; + + var filePath = files[0].Path.LocalPath; + var data = await Task.Run(() => File.ReadAllBytes(filePath)); + + var entry = SelectedNode.Entry; + entry.SetData(data, entry.IsCompressed); + + IsDirty = true; + NotifyStatusChanged(); + + // Refresh the selected entry view + var entryVm = new EntryViewModel(entry); + entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; + SelectedEntry = entryVm; + UpdatePreview(entry); + } + + [RelayCommand] + private void DeleteEntry() + { + if (SelectedNode?.Entry == null || _archive == null) return; + + _archive.Entries.Remove(SelectedNode.Entry); + IsDirty = true; + SelectedNode = null; + SelectedEntry = null; + ClearPreview(); + BuildTree(); + NotifyStatusChanged(); + } + + [RelayCommand] + private async Task RenameEntry() + { + if (SelectedNode?.Entry == null || _window == null) return; + + var entry = SelectedNode.Entry; + var result = await Dialogs.InputAsync(_window, "Rename Entry", "New alias:", entry.Alias); + + if (result != null && result != entry.Alias && result.Length > 0) + { + entry.Alias = result; + IsDirty = true; + BuildTree(); + NotifyStatusChanged(); + + if (SelectedEntry != null) + { + var entryVm = new EntryViewModel(entry); + entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; + SelectedEntry = entryVm; + } + } + } + + [RelayCommand] + private async Task ExtractFolder() + { + if (SelectedNode == null || !SelectedNode.IsFolder || _archive == null || _window == null) return; + + var folders = await _window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Extraction Folder" + }); + + if (folders.Count == 0) return; + + var basePath = folders[0].Path.LocalPath; + var prefix = SelectedNode.FullPath; + if (!prefix.EndsWith("/")) prefix += "/"; + + try + { + var matching = _archive.Entries.Where(e => e.Alias.StartsWith(prefix, StringComparison.Ordinal)).ToList(); + + await Task.Run(() => + { + foreach (var entry in matching) + { + var relativePath = entry.Alias.Substring(prefix.Length); + var outputPath = Path.Combine(basePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var dir = Path.GetDirectoryName(outputPath); + if (dir != null) Directory.CreateDirectory(dir); + + var data = entry.GetDecompressedData(); + File.WriteAllBytes(outputPath, data); + } + }); + + await Dialogs.ShowMessageAsync(_window, "Extract Folder", + $"Successfully extracted files to:\n{basePath}"); + } + catch (Exception ex) + { + await Dialogs.ShowMessageAsync(_window, "Error", $"Extraction failed:\n{ex.Message}"); + } + } + + // ========================================================================= + // Text editing commands + // ========================================================================= + + [RelayCommand] + private void StartEdit() + { + IsEditingText = true; + } + + [RelayCommand] + private void SaveEdit() + { + if (SelectedNode?.Entry == null || PreviewText == null) return; + + var entry = SelectedNode.Entry; + var newData = Encoding.UTF8.GetBytes(PreviewText); + entry.SetData(newData, entry.IsCompressed); + + IsDirty = true; + IsEditingText = false; + + var entryVm = new EntryViewModel(entry); + entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; + SelectedEntry = entryVm; + NotifyStatusChanged(); + } + + [RelayCommand] + private void CancelEdit() + { + IsEditingText = false; + if (SelectedNode?.Entry != null) + { + PreviewText = Encoding.UTF8.GetString(SelectedNode.Entry.GetDecompressedData()); + } + } + + // ========================================================================= + // About + // ========================================================================= + + [RelayCommand] + private async Task ShowAbout() + { + if (_window == null) return; + await Dialogs.ShowMessageAsync(_window, "About GsaEditor", + "GsaEditor v1.0\n\nGSA Archive Viewer & Editor\nBuilt with Avalonia UI"); + } + + // ========================================================================= + // Tree building + // ========================================================================= + + private void BuildTree() + { + TreeRoots.Clear(); + if (_archive == null) return; + + foreach (var entry in _archive.Entries) + { + var parts = entry.Alias.Split('/'); + var currentLevel = TreeRoots; + var currentPath = ""; + + for (int i = 0; i < parts.Length; i++) + { + var part = parts[i]; + currentPath = i == 0 ? part : currentPath + "/" + part; + bool isLeaf = (i == parts.Length - 1); + + var existing = currentLevel.FirstOrDefault(n => n.Name == part); + if (existing == null) + { + existing = new EntryTreeNodeViewModel + { + Name = part, + FullPath = currentPath, + IsFolder = !isLeaf, + Entry = isLeaf ? entry : null + }; + currentLevel.Add(existing); + } + + if (!isLeaf) + { + currentLevel = existing.Children; + } + } + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private void NotifyStatusChanged() + { + OnPropertyChanged(nameof(StatusEntryCount)); + OnPropertyChanged(nameof(StatusSizeInfo)); + OnPropertyChanged(nameof(HasArchive)); + } + + private static string FormatSize(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } +} diff --git a/GsaEditor/ViewModels/PreviewMode.cs b/GsaEditor/ViewModels/PreviewMode.cs new file mode 100644 index 0000000..ee1a9b7 --- /dev/null +++ b/GsaEditor/ViewModels/PreviewMode.cs @@ -0,0 +1,12 @@ +namespace GsaEditor.ViewModels; + +/// +/// Determines which preview panel to show for the selected archive entry. +/// +public enum PreviewMode +{ + None, + Text, + Image, + HexDump +} diff --git a/GsaEditor/ViewModels/ViewModelBase.cs b/GsaEditor/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..41d93f9 --- /dev/null +++ b/GsaEditor/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GsaEditor.ViewModels; + +public class ViewModelBase : ObservableObject +{ +} diff --git a/GsaEditor/Views/MainWindow.axaml b/GsaEditor/Views/MainWindow.axaml new file mode 100644 index 0000000..58d2f87 --- /dev/null +++ b/GsaEditor/Views/MainWindow.axaml @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +