diff --git a/Directory.Packages.props b/Directory.Packages.props
index bb225235..7de50230 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -23,7 +23,7 @@
-
+
\ No newline at end of file
diff --git a/Knossos.NET.Desktop/Assets/utils/innoextract.license b/Knossos.NET.Android/natives/3rdparty/innoextract/LICENSE
similarity index 100%
rename from Knossos.NET.Desktop/Assets/utils/innoextract.license
rename to Knossos.NET.Android/natives/3rdparty/innoextract/LICENSE
diff --git a/Knossos.NET.Android/natives/arm64-v8a/libinnowrap.so b/Knossos.NET.Android/natives/arm64-v8a/libinnowrap.so
new file mode 100644
index 00000000..4f3c438e
Binary files /dev/null and b/Knossos.NET.Android/natives/arm64-v8a/libinnowrap.so differ
diff --git a/Knossos.NET.Android/natives/armeabi-v7a/libinnowrap.so b/Knossos.NET.Android/natives/armeabi-v7a/libinnowrap.so
new file mode 100644
index 00000000..a65786b1
Binary files /dev/null and b/Knossos.NET.Android/natives/armeabi-v7a/libinnowrap.so differ
diff --git a/Knossos.NET.Android/natives/x86/libinnowrap.so b/Knossos.NET.Android/natives/x86/libinnowrap.so
new file mode 100644
index 00000000..e4dad008
Binary files /dev/null and b/Knossos.NET.Android/natives/x86/libinnowrap.so differ
diff --git a/Knossos.NET.Android/natives/x86_64/libinnowrap.so b/Knossos.NET.Android/natives/x86_64/libinnowrap.so
new file mode 100644
index 00000000..d6660e10
Binary files /dev/null and b/Knossos.NET.Android/natives/x86_64/libinnowrap.so differ
diff --git a/Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64 b/Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64
deleted file mode 100644
index 03168575..00000000
Binary files a/Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64 and /dev/null differ
diff --git a/Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract b/Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract
deleted file mode 100644
index 6f90a44d..00000000
Binary files a/Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract and /dev/null differ
diff --git a/Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64 b/Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64
deleted file mode 100644
index a8443f0a..00000000
Binary files a/Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64 and /dev/null differ
diff --git a/Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64 b/Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64
deleted file mode 100644
index 3d9237d7..00000000
Binary files a/Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64 and /dev/null differ
diff --git a/Knossos.NET.Desktop/Assets/utils/win/innoextract.exe b/Knossos.NET.Desktop/Assets/utils/win/innoextract.exe
deleted file mode 100644
index 22c33dc1..00000000
Binary files a/Knossos.NET.Desktop/Assets/utils/win/innoextract.exe and /dev/null differ
diff --git a/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
index f5f5578f..dac32dfb 100644
--- a/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
+++ b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
@@ -11,6 +11,7 @@
copyused
true
true
+ AnyCPU;x64
@@ -27,27 +28,109 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_IsWin Condition="$(RuntimeIdentifier.StartsWith('win')) Or
+ ($(RuntimeIdentifier) == '' And $([MSBuild]::IsOSPlatform('Windows')))">true
+ <_IsLinux Condition="$(RuntimeIdentifier.StartsWith('linux')) Or
+ ($(RuntimeIdentifier) == '' And $([MSBuild]::IsOSPlatform('Linux')))">true
+ <_IsOsx Condition="$(RuntimeIdentifier.StartsWith('osx')) Or
+ ($(RuntimeIdentifier) == '' And $([MSBuild]::IsOSPlatform('OSX')))">true
+
+
+ <_IsX64 Condition="$(RuntimeIdentifier.Contains('x64')) Or
+ ('$(RuntimeIdentifier)' == '' And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64')">true
+ <_IsX86 Condition="$(RuntimeIdentifier.Contains('x86')) Or
+ ('$(RuntimeIdentifier)' == '' And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X86')">true
+ <_IsArm64 Condition="$(RuntimeIdentifier.Contains('arm64')) Or
+ ('$(RuntimeIdentifier)' == '' And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64')">true
+ <_IsRiscV64 Condition="$(RuntimeIdentifier.Contains('riscv64'))">true
+
+
+
+
+
+ PreserveNewest
+ innowrap.dll
+
+
+
+
+ PreserveNewest
+ innowrap.dll
+
+
+
+
+ PreserveNewest
+ innowrap.dll
+
+
+
+
+
+
+ PreserveNewest
+ libinnowrap.so
+
+
+
+
+
+
+ PreserveNewest
+ libinnowrap.so
+
+
+
+
+
+
+ PreserveNewest
+ libinnowrap.so
+
+
+
+
+
+
+ PreserveNewest
+ libinnowrap.dylib
+
+
+
+
diff --git a/Knossos.NET.Desktop/precompiled/3rdparty/innoextract/LICENSE b/Knossos.NET.Desktop/precompiled/3rdparty/innoextract/LICENSE
new file mode 100644
index 00000000..446f0c78
--- /dev/null
+++ b/Knossos.NET.Desktop/precompiled/3rdparty/innoextract/LICENSE
@@ -0,0 +1,18 @@
+
+Copyright (C) 2011-2020 Daniel Scharrer
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the author(s) be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+3. This notice may not be removed or altered from any source distribution.
diff --git a/Knossos.NET.Desktop/precompiled/linux/arm64/libinnowrap.so b/Knossos.NET.Desktop/precompiled/linux/arm64/libinnowrap.so
new file mode 100644
index 00000000..cc0ec872
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/arm64/libinnowrap.so differ
diff --git a/Knossos.NET.Desktop/precompiled/linux/riscv64/libinnowrap.so b/Knossos.NET.Desktop/precompiled/linux/riscv64/libinnowrap.so
new file mode 100644
index 00000000..c63711a3
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/riscv64/libinnowrap.so differ
diff --git a/Knossos.NET.Desktop/precompiled/linux/x64/libinnowrap.so b/Knossos.NET.Desktop/precompiled/linux/x64/libinnowrap.so
new file mode 100644
index 00000000..f3062a24
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/x64/libinnowrap.so differ
diff --git a/Knossos.NET.Desktop/precompiled/osx/libinnowrap.dylib b/Knossos.NET.Desktop/precompiled/osx/libinnowrap.dylib
new file mode 100644
index 00000000..aae2aa2b
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/osx/libinnowrap.dylib differ
diff --git a/Knossos.NET.Desktop/precompiled/windows/arm64/innowrap.dll b/Knossos.NET.Desktop/precompiled/windows/arm64/innowrap.dll
new file mode 100644
index 00000000..a704bd78
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/arm64/innowrap.dll differ
diff --git a/Knossos.NET.Desktop/precompiled/windows/x64/innowrap.dll b/Knossos.NET.Desktop/precompiled/windows/x64/innowrap.dll
new file mode 100644
index 00000000..e362ac55
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/x64/innowrap.dll differ
diff --git a/Knossos.NET.Desktop/precompiled/windows/x86/innowrap.dll b/Knossos.NET.Desktop/precompiled/windows/x86/innowrap.dll
new file mode 100644
index 00000000..e682207c
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/x86/innowrap.dll differ
diff --git a/Knossos.NET/Classes/Inno/InnoArchive.cs b/Knossos.NET/Classes/Inno/InnoArchive.cs
new file mode 100644
index 00000000..9a54dc61
--- /dev/null
+++ b/Knossos.NET/Classes/Inno/InnoArchive.cs
@@ -0,0 +1,1128 @@
+// InnoArchive.cs
+//
+// Managed wrapper around the native "innowrap" library (a parser-only build of
+// innoextract). The native side ONLY parses the Inno Setup headers and returns
+// per-file metadata; this class owns the installer stream, feeds bytes to the
+// parser through callbacks, and performs ALL decompression with SharpCompress.
+//
+// Dependencies: SharpCompress (https://github.com/adamhathcock/sharpcompress).
+//
+// Native library name resolves to: innowrap.dll (Windows) / libinnowrap.so
+// (Linux/Android) / libinnowrap.dylib (macOS), via NativeLibrary search rules.
+//
+// Typical use:
+// using var archive = new InnoArchive(File.OpenRead(gogSetupExe));
+// foreach (var f in archive.Files) Console.WriteLine(f.Name);
+// var vp = archive.FindFile("root_fs2.vp");
+// using var outFs = File.Create(Path.Combine(dest, "root_fs2.vp"));
+// archive.ExtractTo(vp, outFs);
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using SharpCompress.Compressors;
+using SharpCompress.Compressors.Deflate;
+
+namespace Knossos.NET.Classes.Inno
+{
+ public sealed class InnoExtractException : Exception
+ {
+ public InnoExtractException(string message) : base(message) { }
+ }
+
+ /// Compression of a chunk's payload (matches innoextract's enum).
+ public enum InnoCompression { Stored = 0, Zlib = 1, BZip2 = 2, Lzma1 = 3, Lzma2 = 4, Unknown = 5 }
+
+ /// Per-file pre-compression transform (matches innoextract's enum).
+ public enum InnoFilter { None = 0, Instruction4108 = 1, Instruction5200 = 2, Instruction5309 = 3, Zlib = 4 }
+
+ public enum InnoChecksumType { None = 0, Adler32 = 1, Crc32 = 2, Md5 = 3, Sha1 = 4, Sha256 = 5 }
+
+ /// Metadata describing one file inside the installer.
+ public sealed class InnoFile
+ {
+ /// Lowercased basename, e.g. "root_fs2.vp". Convenient for matching.
+ public string Name { get; init; } = "";
+ /// Full install destination path as stored, e.g. "{app}\\data\\root_fs2.vp".
+ public string Path { get; init; } = "";
+ /// Final (post-filter) uncompressed size in bytes.
+ public ulong Size { get; init; }
+
+ /// Whole-file checksum (over the concatenation of all parts' output).
+ internal InnoChecksumType ChecksumType;
+ internal byte[] Checksum = Array.Empty();
+
+ ///
+ /// One or more parts to extract and concatenate. Non-GOG files have exactly one
+ /// part; GOG Galaxy files are split into many deflated parts.
+ ///
+ internal List Parts = new();
+
+ public int PartCount => Parts.Count;
+
+ public override string ToString() => $"{Name} ({Size} bytes, {Parts.Count} part(s))";
+ }
+
+ // One part = one chunk in the .exe (embedded) or a .bin slice (external).
+ internal sealed class InnoPart
+ {
+ public uint FirstSlice;
+ public uint LastSlice;
+ public ulong ChunkOffset;
+ public ulong ChunkSize;
+ public InnoCompression Compression;
+ public int Encryption;
+ public ulong FileOffset; // offset within the decompressed chunk
+ public ulong FileSize; // pre-filter size within the decompressed chunk
+ public InnoFilter Filter;
+ }
+
+ public sealed class InnoArchive : IDisposable
+ {
+ // ------------------------------------------------------------------ //
+ // Native interop
+ // ------------------------------------------------------------------ //
+ private const string Lib = "innowrap";
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate long ReadFn(IntPtr user, ulong offset, IntPtr buf, long len);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate long InflateFn(IntPtr user, int op, long handle, int method, IntPtr inPtr, long inLen, IntPtr outPtr, long outCap);
+
+ // Field order MUST match struct innowrap_file in innowrap.cpp exactly.
+ // Pointer-free + naturally aligned, so the layout is identical on 32- and 64-bit.
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativeFile
+ {
+ public ulong size;
+ public int part_count;
+ public int checksum_type;
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
+ public byte[] checksum;
+ }
+
+ // Field order MUST match struct innowrap_part in innowrap.cpp exactly.
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativePart
+ {
+ public ulong chunk_offset;
+ public ulong chunk_size;
+ public ulong file_offset;
+ public ulong file_size;
+ public uint first_slice;
+ public uint last_slice;
+ public int compression;
+ public int encryption;
+ public int filter;
+ public int reserved;
+ }
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr innowrap_version();
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr innowrap_open(ReadFn readCb, InflateFn inflateCb, IntPtr user, ulong totalSize, out int err);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern int innowrap_file_count(IntPtr ctx);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern ulong innowrap_data_offset(IntPtr ctx);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern uint innowrap_slices_per_disk(IntPtr ctx);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr innowrap_base_filename(IntPtr ctx);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern int innowrap_get_file(IntPtr ctx, int index, out NativeFile outFile);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern int innowrap_get_part(IntPtr ctx, int fileIndex, int partIndex, out NativePart outPart);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr innowrap_file_name(IntPtr ctx, int index);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr innowrap_file_path(IntPtr ctx, int index);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern void innowrap_close(IntPtr ctx);
+
+ // ------------------------------------------------------------------ //
+ // State
+ // ------------------------------------------------------------------ //
+ private readonly Stream _stream;
+ private readonly bool _leaveOpen;
+ private readonly object _io = new();
+ private IntPtr _ctx;
+ private ulong _dataOffset;
+
+ // External-slice (.bin) support. Slices are opened through a SliceOpener so the
+ // caller controls HOW (File.OpenRead on desktop, Avalonia IStorageFile on mobile).
+ private SliceOpener? _sliceOpener;
+ private string _baseName = ""; // installer stem, for .bin naming
+ private uint _slicesPerDisk = 1;
+ private string _baseFilename = ""; // header fallback base name
+ private SliceSource? _slices;
+
+ // Keep delegates rooted so the GC doesn't collect them while native code holds pointers.
+ private readonly ReadFn _readCb;
+ private readonly InflateFn _inflateCb;
+
+ // Reusable scratch for the read callback + live header decoders for the inflate callback.
+ private byte[] _readScratch = new byte[1 << 16];
+ private readonly Dictionary _inflaters = new();
+ private long _nextInflateHandle;
+
+ private List _files = new();
+ public IReadOnlyList Files => _files;
+ public ulong DataOffset => _dataOffset;
+ /// True when file data lives in external .bin slices rather than inside the .exe.
+ public bool IsExternal => _dataOffset == 0;
+
+ public static string Version => Marshal.PtrToStringUTF8(innowrap_version()) ?? "";
+
+ ///
+ /// Opens a slice (.bin) by index and expected filename. Return a readable stream
+ /// (seekable preferred; a forward-only stream works with ),
+ /// or null if not available.
+ ///
+ public delegate Stream? SliceOpener(int sliceIndex, string expectedFileName);
+
+ ///
+ /// Open an installer by path (desktop). Locates sibling .bin slices with File I/O.
+ ///
+ public InnoArchive(string installerPath)
+ : this(OpenSeekable(File.OpenRead(installerPath)), false,
+ Path.GetFileNameWithoutExtension(installerPath),
+ DefaultFileOpener(Path.GetDirectoryName(Path.GetFullPath(installerPath)) ?? "."))
+ { }
+
+ ///
+ /// Open an installer from streams (mobile / sandboxed). The .exe is read via
+ /// ; external .bin slices are obtained through
+ /// . is the .exe file
+ /// name (e.g. "setup_freespace_2_..._(33372).exe"), used to derive .bin names.
+ /// The .exe stream need not be seekable (it is small and buffered if necessary).
+ ///
+ public InnoArchive(Stream exeStream, string installerName, SliceOpener openSlice, bool leaveOpen = false)
+ : this(OpenSeekable(exeStream), leaveOpen,
+ Path.GetFileNameWithoutExtension(installerName), openSlice)
+ { }
+
+ /// Embedded single-.exe installers only (no external .bin access).
+ public InnoArchive(Stream stream, bool leaveOpen = false)
+ : this(OpenSeekable(stream), leaveOpen, "", null) { }
+
+ private InnoArchive(Stream seekableExe, bool leaveOpen, string baseName, SliceOpener? openSlice)
+ {
+ _stream = seekableExe;
+ _leaveOpen = leaveOpen;
+ _baseName = baseName;
+ _sliceOpener = openSlice;
+ _readCb = ReadCallback;
+ _inflateCb = InflateCallback;
+
+ ulong total = (ulong)_stream.Length;
+ _ctx = innowrap_open(_readCb, _inflateCb, IntPtr.Zero, total, out int err);
+ if (_ctx == IntPtr.Zero || err != 0)
+ throw new InnoExtractException("innowrap_open failed (not an Inno Setup installer, or parse error).");
+
+ _dataOffset = innowrap_data_offset(_ctx);
+ _slicesPerDisk = Math.Max(1u, innowrap_slices_per_disk(_ctx));
+ _baseFilename = Marshal.PtrToStringUTF8(innowrap_base_filename(_ctx)) ?? "";
+ LoadFileList();
+ }
+
+ // The .exe is small; if the provided stream can't seek (e.g. an Android content
+ // stream), buffer it into a MemoryStream so the parser can seek freely.
+ private static Stream OpenSeekable(Stream s)
+ {
+ if (s == null) throw new ArgumentNullException(nameof(s));
+ if (!s.CanRead) throw new ArgumentException("Stream must be readable.");
+ if (s.CanSeek) return s;
+ var ms = new MemoryStream();
+ s.CopyTo(ms);
+ s.Dispose();
+ ms.Position = 0;
+ return ms;
+ }
+
+ // Desktop default: open .bin siblings from a directory by filename.
+ private static SliceOpener DefaultFileOpener(string dir) =>
+ (idx, name) =>
+ {
+ string p = Path.Combine(dir, name);
+ if (File.Exists(p)) return File.Open(p, FileMode.Open, FileAccess.Read, FileShare.Read);
+ if (Directory.Exists(dir))
+ foreach (var f in Directory.EnumerateFiles(dir))
+ if (string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase))
+ return File.Open(f, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return null;
+ };
+
+ private void LoadFileList()
+ {
+ int n = innowrap_file_count(_ctx);
+ var list = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ if (innowrap_get_file(_ctx, i, out NativeFile nf) != 0) continue;
+
+ var parts = new List(nf.part_count);
+ for (int p = 0; p < nf.part_count; p++)
+ {
+ if (innowrap_get_part(_ctx, i, p, out NativePart np) != 0) continue;
+ parts.Add(new InnoPart
+ {
+ FirstSlice = np.first_slice,
+ LastSlice = np.last_slice,
+ ChunkOffset = np.chunk_offset,
+ ChunkSize = np.chunk_size,
+ Compression = (InnoCompression)np.compression,
+ Encryption = np.encryption,
+ FileOffset = np.file_offset,
+ FileSize = np.file_size,
+ Filter = (InnoFilter)np.filter,
+ });
+ }
+
+ list.Add(new InnoFile
+ {
+ Name = Marshal.PtrToStringUTF8(innowrap_file_name(_ctx, i)) ?? "",
+ Path = Marshal.PtrToStringUTF8(innowrap_file_path(_ctx, i)) ?? "",
+ Size = nf.size,
+ ChecksumType = (InnoChecksumType)nf.checksum_type,
+ Checksum = nf.checksum ?? Array.Empty(),
+ Parts = parts,
+ });
+ }
+ _files = list;
+ }
+
+ /// Find a file by lowercased basename (e.g. "root_fs2.vp"). Null if absent.
+ public InnoFile? FindFile(string nameLower)
+ {
+ foreach (var f in _files)
+ if (string.Equals(f.Name, nameLower, StringComparison.OrdinalIgnoreCase)) return f;
+ return null;
+ }
+
+ // ------------------------------------------------------------------ //
+ // Native callbacks
+ // ------------------------------------------------------------------ //
+ private long ReadCallback(IntPtr user, ulong offset, IntPtr buf, long len)
+ {
+ try
+ {
+ if (len <= 0) return 0;
+ lock (_io)
+ {
+ if (_readScratch.Length < len) _readScratch = new byte[len];
+ _stream.Seek((long)offset, SeekOrigin.Begin);
+ int total = 0;
+ while (total < len)
+ {
+ int got = _stream.Read(_readScratch, total, (int)(len - total));
+ if (got <= 0) break;
+ total += got;
+ }
+ Marshal.Copy(_readScratch, 0, buf, total);
+ return total;
+ }
+ }
+ catch { return -1; }
+ }
+
+ // Streaming header-block decompression.
+ // op 0 OPEN : method 1 = zlib, 2 = inno LZMA1; `in` is the full block. Returns a handle.
+ // op 1 READ : produce up to outCap bytes into outPtr from the decoder `handle`.
+ // op 2 CLOSE : dispose decoder `handle`.
+ // Streaming (rather than decode-all) is required because inno LZMA1 streams carry no
+ // end-of-stream marker; we decode only what the parser pulls, exactly like innoextract.
+ private long InflateCallback(IntPtr user, int op, long handle, int method, IntPtr inPtr, long inLen, IntPtr outPtr, long outCap)
+ {
+ try
+ {
+ switch (op)
+ {
+ case 0: // OPEN
+ {
+ var input = new byte[inLen];
+ if (inLen > 0) Marshal.Copy(inPtr, input, 0, (int)inLen);
+ Stream dec = method switch
+ {
+ 1 => new ZlibStream(new MemoryStream(input), CompressionMode.Decompress),
+ 2 => OpenInnoLzma1(input),
+ _ => throw new InnoExtractException($"unexpected header compression method {method}")
+ };
+ long h = _nextInflateHandle++;
+ _inflaters[h] = dec;
+ return h;
+ }
+
+ case 1: // READ
+ {
+ if (!_inflaters.TryGetValue(handle, out var dec)) return -1;
+ if (outCap <= 0) return 0;
+ if (_readScratch.Length < outCap) _readScratch = new byte[outCap];
+ int got;
+ try { got = dec.Read(_readScratch, 0, (int)outCap); }
+ catch { got = 0; } // tolerate a decode-end overrun: report EOF
+ if (got <= 0) return 0;
+ Marshal.Copy(_readScratch, 0, outPtr, got);
+ return got;
+ }
+
+ case 2: // CLOSE
+ {
+ if (_inflaters.TryGetValue(handle, out var dec))
+ {
+ _inflaters.Remove(handle);
+ dec.Dispose();
+ }
+ return 0;
+ }
+
+ default:
+ return -1;
+ }
+ }
+ catch { return -1; }
+ }
+
+ // Header-block LZMA1: 5-byte properties header + raw LZMA1, decoded on demand
+ // (outputSize = -1; we never force it to EOF, so the missing end marker is harmless).
+ private static Stream OpenInnoLzma1(byte[] input)
+ {
+ if (input.Length < 5) throw new InnoExtractException("inno LZMA1 header too short.");
+ var props = new byte[5];
+ Array.Copy(input, 0, props, 0, 5);
+ var ms = new MemoryStream(input, 5, input.Length - 5);
+ return InnoSharpCompat.Lzma1(props, ms, input.Length - 5, -1);
+ }
+
+ // ------------------------------------------------------------------ //
+ // Extraction
+ // ------------------------------------------------------------------ //
+ private static readonly byte[] ChunkMagic = { 0x7a, 0x6c, 0x62, 0x1a }; // "zlb\x1a"
+
+ ///
+ /// Decompress one file out of the installer and write it to .
+ /// For GOG Galaxy installers the file is reassembled from many deflated parts.
+ /// All decompression happens here in managed code via SharpCompress.
+ ///
+ public void ExtractTo(InnoFile file, Stream output)
+ {
+ if (file == null) throw new ArgumentNullException(nameof(file));
+ if (file.Parts.Count == 0)
+ throw new InnoExtractException($"{file.Name} has no data parts.");
+
+ var hasher = new RunningChecksum(file.ChecksumType);
+
+ foreach (InnoPart part in file.Parts)
+ {
+ if (part.Encryption != 0)
+ throw new InnoExtractException("Encrypted installers are not supported by this build.");
+ ExtractPart(part, output, hasher);
+ }
+
+ // Verify the whole-file checksum over the concatenation of all parts.
+ if (file.ChecksumType != InnoChecksumType.None)
+ {
+ byte[] actual = hasher.Final();
+ byte[] expected = file.Checksum;
+ for (int i = 0; i < actual.Length; i++)
+ if (actual[i] != expected[i])
+ throw new InnoExtractException($"Checksum mismatch for {file.Name} ({file.ChecksumType}).");
+ }
+ }
+
+ ///
+ /// Extract several files in a SINGLE forward pass over each .bin. This is the
+ /// recommended path on sandboxed platforms (e.g. Android) where the .bin stream may
+ /// not be seekable: each slice is read once start-to-finish, so a forward-only
+ /// stream works and no temporary copy of the 1.4 GB .bin is needed. The output
+ /// stream for each file is obtained from and is written
+ /// (and its whole-file checksum verified) as its parts are encountered.
+ ///
+ public void ExtractFiles(IReadOnlyCollection files, Func outputFor,
+ Action? onFileExtracted = null)
+ {
+ if (files == null) throw new ArgumentNullException(nameof(files));
+
+ // Embedded (.exe) data is seekable (buffered if needed): per-file is fine.
+ if (!IsExternal)
+ {
+ foreach (var f in files)
+ {
+ using (var outp = outputFor(f))
+ ExtractTo(f, outp);
+ onFileExtracted?.Invoke(f);
+ }
+ return;
+ }
+
+ if (_sliceOpener == null)
+ throw new InnoExtractException("External installer requires a SliceOpener (or path constructor).");
+
+ // Global work list, sorted for a strict forward pass over the .bin(s).
+ var work = new List<(InnoFile file, InnoPart part)>();
+ foreach (var f in files)
+ foreach (var p in f.Parts)
+ {
+ if (p.Encryption != 0) throw new InnoExtractException("Encrypted installers are not supported.");
+ if (p.FirstSlice != p.LastSlice)
+ throw new InnoExtractException(
+ "A part spans multiple .bin slices; use the seekable path for this installer.");
+ work.Add((f, p));
+ }
+ work.Sort((a, b) =>
+ {
+ int c = a.part.FirstSlice.CompareTo(b.part.FirstSlice);
+ return c != 0 ? c : a.part.ChunkOffset.CompareTo(b.part.ChunkOffset);
+ });
+
+ // Per-file streaming state.
+ var outStream = new Dictionary();
+ var hashers = new Dictionary();
+ var remaining = new Dictionary();
+ foreach (var f in files)
+ {
+ outStream[f] = outputFor(f);
+ hashers[f] = new RunningChecksum(f.ChecksumType);
+ remaining[f] = f.Parts.Count;
+ }
+
+ try
+ {
+ using var reader = new ForwardBinReader(_sliceOpener, _baseName, _baseFilename, _slicesPerDisk);
+ foreach (var (f, part) in work)
+ {
+ long span = checked((long)part.ChunkSize + 4);
+ using (Stream w = reader.Window((int)part.FirstSlice, (long)part.ChunkOffset, span))
+ DecodePart(w, part, outStream[f], hashers[f]);
+
+ if (--remaining[f] == 0)
+ {
+ if (f.ChecksumType != InnoChecksumType.None)
+ {
+ byte[] actual = hashers[f].Final();
+ byte[] expected = f.Checksum;
+ for (int i = 0; i < actual.Length; i++)
+ if (actual[i] != expected[i])
+ throw new InnoExtractException($"Checksum mismatch for {f.Name} ({f.ChecksumType}).");
+ }
+ onFileExtracted?.Invoke(f);
+ }
+ }
+ }
+ finally
+ {
+ foreach (var s in outStream.Values) { try { s.Dispose(); } catch { } }
+ }
+ }
+
+ // Extract a single part via random access (seekable sources).
+ private void ExtractPart(InnoPart part, Stream output, RunningChecksum hasher)
+ {
+ long span = checked((long)part.ChunkSize + 4); // 4-byte magic + payload
+ Stream chunkStream = OpenChunkStream(part, span);
+ try { DecodePart(chunkStream, part, output, hasher); }
+ finally { chunkStream.Dispose(); }
+ }
+
+ // Decode one part from a stream positioned at the chunk magic (spanning 4 + ChunkSize
+ // bytes): verify magic, decompress the chunk, skip to FileOffset, read FileSize bytes,
+ // apply the part filter, stream to `output` while feeding the running checksum.
+ private void DecodePart(Stream chunkStream, InnoPart part, Stream output, RunningChecksum hasher)
+ {
+ Span magic = stackalloc byte[4];
+ ReadExact(chunkStream, magic);
+ if (!magic.SequenceEqual(ChunkMagic))
+ throw new InnoExtractException("Chunk magic mismatch (\"zlb\\x1a\" expected).");
+
+ long needed = checked((long)(part.FileOffset + part.FileSize));
+ Stream decompressed = part.Compression switch
+ {
+ InnoCompression.Stored => chunkStream,
+ InnoCompression.Zlib => new ZlibStream(chunkStream, CompressionMode.Decompress),
+ InnoCompression.BZip2 => InnoSharpCompat.BZip2(chunkStream),
+ InnoCompression.Lzma1 => MakeInnoLzma1Stream(chunkStream, needed),
+ InnoCompression.Lzma2 => MakeInnoLzma2Stream(chunkStream, needed),
+ _ => throw new InnoExtractException($"Unsupported chunk compression {part.Compression}.")
+ };
+ try
+ {
+ SkipExact(decompressed, (long)part.FileOffset);
+ using var payload = new BoundedStream(decompressed, (long)part.FileSize);
+
+ switch (part.Filter)
+ {
+ case InnoFilter.None:
+ PumpToOutput(payload, output, hasher);
+ break;
+ case InnoFilter.Zlib:
+ // GOG Galaxy parts are deflated; inflate then write. The whole-file
+ // checksum is over the INFLATED bytes.
+ using (var zs = new ZlibStream(payload, CompressionMode.Decompress))
+ PumpToOutput(zs, output, hasher);
+ break;
+ case InnoFilter.Instruction4108:
+ WriteFiltered(payload, output, hasher, b => InnoExeFilters.Decode4108(b));
+ break;
+ case InnoFilter.Instruction5200:
+ WriteFiltered(payload, output, hasher, b => InnoExeFilters.Decode5200(b, false));
+ break;
+ case InnoFilter.Instruction5309:
+ WriteFiltered(payload, output, hasher, b => InnoExeFilters.Decode5200(b, true));
+ break;
+ default:
+ throw new InnoExtractException($"Unsupported file filter {part.Filter}.");
+ }
+ }
+ finally
+ {
+ if (!ReferenceEquals(decompressed, chunkStream)) decompressed.Dispose();
+ }
+ }
+
+ // Copy a stream to output while updating the running checksum (no full buffering).
+ private static void PumpToOutput(Stream src, Stream output, RunningChecksum hasher)
+ {
+ var buf = new byte[1 << 16];
+ int got;
+ while ((got = src.Read(buf, 0, buf.Length)) > 0)
+ {
+ hasher.Update(buf.AsSpan(0, got));
+ output.Write(buf, 0, got);
+ }
+ }
+
+ // Instruction (exe) filters need the whole part in memory; these parts are small.
+ private static void WriteFiltered(Stream payload, Stream output, RunningChecksum hasher,
+ Func decode)
+ {
+ byte[] raw = ReadAll(payload);
+ byte[] decoded = decode(raw);
+ hasher.Update(decoded);
+ output.Write(decoded, 0, decoded.Length);
+ }
+
+ private SliceSource EnsureSlices()
+ {
+ if (_sliceOpener == null)
+ throw new InnoExtractException(
+ "This installer stores its data in external .bin slices. Open it with the " +
+ "path constructor, or the (Stream, name, SliceOpener) constructor on mobile.");
+ return _slices ??= new SliceSource(_sliceOpener, _baseName, _baseFilename, _slicesPerDisk);
+ }
+
+ // Random-access chunk stream at the magic, bounded to `span` bytes.
+ private Stream OpenChunkStream(InnoPart part, long span)
+ {
+ if (!IsExternal)
+ {
+ long start = checked((long)(_dataOffset + part.ChunkOffset));
+ // SharpCompress's LZMA decoder pulls compressed input one byte at a time.
+ // Buffer the bounded window so Android content streams do not perform a
+ // managed-to-Java read + seek round trip for every compressed byte.
+ return new BufferedStream(new WindowStream(_stream, start, span, _io), 1 << 16);
+ }
+ return new SliceStream(EnsureSlices(), part.FirstSlice, (long)part.ChunkOffset, span);
+ }
+
+ // ------------------------------------------------------------------ //
+ // SharpCompress helpers
+ // ------------------------------------------------------------------ //
+ // Chunk LZMA1: identical 5-byte header, but we know exactly how many output
+ // bytes we need (outputSize), so the decoder stops cleanly.
+ private static Stream MakeInnoLzma1Stream(Stream input, long outputSize)
+ {
+ var props = new byte[5];
+ ReadExact(input, props);
+ return InnoSharpCompat.Lzma1(props, input, -1, outputSize);
+ }
+
+ // Chunk LZMA2: single dict-size property byte, then raw LZMA2.
+ private static Stream MakeInnoLzma2Stream(Stream input, long outputSize)
+ {
+ int b = input.ReadByte();
+ if (b < 0) throw new InnoExtractException("inno LZMA2 header missing.");
+ var props = new byte[] { (byte)b };
+ return InnoSharpCompat.Lzma2(props, input, -1, outputSize);
+ }
+
+ // ------------------------------------------------------------------ //
+ // Checksums (incremental, so multi-hundred-MB files aren't buffered)
+ // ------------------------------------------------------------------ //
+ private sealed class RunningChecksum
+ {
+ private readonly InnoChecksumType _type;
+ private readonly IncrementalHash? _ih;
+ private uint _crc = 0xFFFFFFFF;
+ private uint _adlerA = 1, _adlerB = 0;
+
+ public RunningChecksum(InnoChecksumType type)
+ {
+ _type = type;
+ _ih = type switch
+ {
+ InnoChecksumType.Md5 => IncrementalHash.CreateHash(HashAlgorithmName.MD5),
+ InnoChecksumType.Sha1 => IncrementalHash.CreateHash(HashAlgorithmName.SHA1),
+ InnoChecksumType.Sha256 => IncrementalHash.CreateHash(HashAlgorithmName.SHA256),
+ _ => null
+ };
+ }
+
+ public void Update(ReadOnlySpan data)
+ {
+ switch (_type)
+ {
+ case InnoChecksumType.Md5:
+ case InnoChecksumType.Sha1:
+ case InnoChecksumType.Sha256:
+ _ih!.AppendData(data);
+ break;
+ case InnoChecksumType.Crc32:
+ foreach (byte by in data) _crc = CrcTable[(_crc ^ by) & 0xFF] ^ (_crc >> 8);
+ break;
+ case InnoChecksumType.Adler32:
+ const uint MOD = 65521;
+ foreach (byte by in data) { _adlerA = (_adlerA + by) % MOD; _adlerB = (_adlerB + _adlerA) % MOD; }
+ break;
+ }
+ }
+
+ public byte[] Final() => _type switch
+ {
+ InnoChecksumType.Md5 or InnoChecksumType.Sha1 or InnoChecksumType.Sha256 => _ih!.GetHashAndReset(),
+ InnoChecksumType.Crc32 => Le32(_crc ^ 0xFFFFFFFF),
+ InnoChecksumType.Adler32 => Le32((_adlerB << 16) | _adlerA),
+ _ => Array.Empty()
+ };
+ }
+
+ private static byte[] Le32(uint v) => new[] { (byte)v, (byte)(v >> 8), (byte)(v >> 16), (byte)(v >> 24) };
+
+ private static readonly uint[] CrcTable = BuildCrcTable();
+ private static uint[] BuildCrcTable()
+ {
+ var t = new uint[256];
+ for (uint i = 0; i < 256; i++)
+ {
+ uint c = i;
+ for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
+ t[i] = c;
+ }
+ return t;
+ }
+
+ // ------------------------------------------------------------------ //
+ // Stream utilities
+ // ------------------------------------------------------------------ //
+ private static void ReadExact(Stream s, Span buf)
+ {
+ int off = 0;
+ while (off < buf.Length)
+ {
+ int got = s.Read(buf.Slice(off));
+ if (got <= 0) throw new EndOfStreamException();
+ off += got;
+ }
+ }
+
+ private static void SkipExact(Stream s, long count)
+ {
+ if (count <= 0) return;
+ var buf = new byte[Math.Min(count, 1 << 16)];
+ long left = count;
+ while (left > 0)
+ {
+ int want = (int)Math.Min(left, buf.Length);
+ int got = s.Read(buf, 0, want);
+ if (got <= 0) throw new EndOfStreamException();
+ left -= got;
+ }
+ }
+
+ private static byte[] ReadCount(Stream s, long count)
+ {
+ var outBuf = new byte[count];
+ long off = 0;
+ while (off < count)
+ {
+ int got = s.Read(outBuf, (int)off, (int)Math.Min(count - off, int.MaxValue));
+ if (got <= 0) throw new EndOfStreamException();
+ off += got;
+ }
+ return outBuf;
+ }
+
+ private static byte[] ReadAll(Stream s)
+ {
+ using var ms = new MemoryStream();
+ var buf = new byte[1 << 16];
+ int got;
+ while ((got = s.Read(buf, 0, buf.Length)) > 0) ms.Write(buf, 0, got);
+ return ms.ToArray();
+ }
+
+ public void Dispose()
+ {
+ if (_ctx != IntPtr.Zero) { innowrap_close(_ctx); _ctx = IntPtr.Zero; }
+ foreach (var d in _inflaters.Values) { try { d.Dispose(); } catch { } }
+ _inflaters.Clear();
+ _slices?.Dispose();
+ if (!_leaveOpen) _stream.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ ~InnoArchive() { if (_ctx != IntPtr.Zero) innowrap_close(_ctx); }
+
+ // ------------------------------------------------------------------ //
+ // External slice (.bin) reading
+ // ------------------------------------------------------------------ //
+ // Opens and caches .bin slices through a SliceOpener. Each slice is
+ // [8-byte magic "idska16/32\x1a"][4-byte LE size][data...]; `size` is the whole file.
+ private sealed class SliceSource : IDisposable
+ {
+ private static readonly byte[][] Magics =
+ {
+ new byte[] { (byte)'i',(byte)'d',(byte)'s',(byte)'k',(byte)'a',(byte)'1',(byte)'6',0x1a },
+ new byte[] { (byte)'i',(byte)'d',(byte)'s',(byte)'k',(byte)'a',(byte)'3',(byte)'2',0x1a },
+ };
+ public const int HeaderSize = 12; // 8 magic + 4 size
+
+ private readonly SliceOpener _open;
+ private readonly string _base, _base2;
+ private readonly uint _slicesPerDisk;
+ private readonly Dictionary _cache = new();
+
+ public SliceSource(SliceOpener opener, string baseName, string baseName2, uint slicesPerDisk)
+ { _open = opener; _base = baseName; _base2 = baseName2 ?? ""; _slicesPerDisk = Math.Max(1u, slicesPerDisk); }
+
+ // {base}-{slice+1}.bin, or {base}-{major}{letter}.bin when slicesPerDisk > 1.
+ public static string SliceFileName(string baseName, long slice, uint slicesPerDisk)
+ {
+ if (slicesPerDisk == 1) return $"{baseName}-{slice + 1}.bin";
+ long major = slice / slicesPerDisk + 1;
+ char minor = (char)('a' + (int)(slice % slicesPerDisk));
+ return $"{baseName}-{major}{minor}.bin";
+ }
+
+ // A seekable stream over slice `slice`, plus its declared size. Cached.
+ public (Stream s, long size) Get(long slice)
+ {
+ if (_cache.TryGetValue(slice, out var e)) return e;
+
+ Stream s = OpenAnyName((int)slice) ?? throw new InnoExtractException(
+ $"Could not open slice {slice}: {SliceFileName(_base, slice, _slicesPerDisk)}" +
+ (string.IsNullOrEmpty(_base2) ? "" : $" or {SliceFileName(_base2, slice, _slicesPerDisk)}"));
+
+ if (!s.CanSeek)
+ {
+ s.Dispose();
+ throw new InnoExtractException(
+ "The .bin slice stream is not seekable. On sandboxed platforms (e.g. Android) " +
+ "use ExtractFiles(...) which does a single forward pass, or provide a seekable stream.");
+ }
+
+ var hdr = new byte[HeaderSize];
+ s.Position = 0;
+ int off = 0; while (off < HeaderSize) { int g = s.Read(hdr, off, HeaderSize - off); if (g <= 0) break; off += g; }
+ if (off < HeaderSize || !MagicOk(hdr))
+ throw new InnoExtractException("Bad slice magic (not a valid .bin).");
+ long size = (uint)(hdr[8] | (hdr[9] << 8) | (hdr[10] << 16) | (hdr[11] << 24));
+ if (size < HeaderSize) throw new InnoExtractException("Bad slice size.");
+ e = (s, size);
+ _cache[slice] = e;
+ return e;
+ }
+
+ private Stream? OpenAnyName(int slice)
+ {
+ foreach (var bn in new[] { _base, _base2 })
+ {
+ if (string.IsNullOrEmpty(bn)) continue;
+ string name = SliceFileName(bn, slice, _slicesPerDisk);
+ Stream? s = _open(slice, name);
+ if (s != null) return s;
+ }
+ return null;
+ }
+
+ private static bool MagicOk(byte[] hdr)
+ {
+ foreach (var m in Magics)
+ {
+ bool ok = true;
+ for (int i = 0; i < 8; i++) if (hdr[i] != m[i]) { ok = false; break; }
+ if (ok) return true;
+ }
+ return false;
+ }
+
+ public void Dispose()
+ {
+ foreach (var e in _cache.Values) { try { e.s.Dispose(); } catch { } }
+ _cache.Clear();
+ }
+ }
+
+ // Forward-only reader over a chunk that starts at an absolute offset in slice `firstSlice`
+ // and may continue into subsequent slices (resuming after each slice's 12-byte header).
+ private sealed class SliceStream : Stream
+ {
+ private readonly SliceSource _src;
+ private long _slice;
+ private long _filePos; // absolute position within the current .bin
+ private long _sliceSize; // declared size of the current .bin
+ private Stream _fs;
+ private long _left; // bytes still to serve for this chunk
+
+ public SliceStream(SliceSource src, long firstSlice, long chunkOffset, long length)
+ {
+ _src = src; _slice = firstSlice; _left = length;
+ var (s, size) = _src.Get(_slice);
+ _fs = s; _sliceSize = size; _filePos = chunkOffset;
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_left <= 0 || count <= 0) return 0;
+
+ long avail = _sliceSize - _filePos;
+ if (avail <= 0)
+ {
+ // Continue in the next slice, right after its 12-byte header.
+ _slice++;
+ var (s, size) = _src.Get(_slice);
+ _fs = s; _sliceSize = size; _filePos = SliceSource.HeaderSize;
+ avail = _sliceSize - _filePos;
+ if (avail <= 0) return 0;
+ }
+
+ int want = (int)Math.Min(Math.Min((long)count, avail), _left);
+ _fs.Seek(_filePos, SeekOrigin.Begin);
+ int got = _fs.Read(buffer, offset, want);
+ if (got <= 0) return 0;
+ _filePos += got; _left -= got;
+ return got;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException();
+ public override void SetLength(long v) => throw new NotSupportedException();
+ public override void Write(byte[] b, int o, int c) => throw new NotSupportedException();
+ // The underlying FileStreams are owned by SliceSource; don't dispose them here.
+ }
+
+ // Reads .bin slices strictly forward (no seeking), for ExtractFiles. Opens one
+ // stream per slice via the SliceOpener; skips forward to each chunk. Tolerates
+ // forward-only streams (e.g. Android content streams).
+ private sealed class ForwardBinReader : IDisposable
+ {
+ private readonly SliceOpener _open;
+ private readonly string _base, _base2;
+ private readonly uint _spd;
+ private int _slice = -1;
+ private Stream? _s;
+ private long _pos; // absolute position within the current .bin
+
+ public ForwardBinReader(SliceOpener open, string b, string b2, uint spd)
+ { _open = open; _base = b; _base2 = b2 ?? ""; _spd = spd; }
+
+ private void Ensure(int slice)
+ {
+ if (slice == _slice && _s != null) return;
+ _s?.Dispose(); _s = null;
+ foreach (var bn in new[] { _base, _base2 })
+ {
+ if (string.IsNullOrEmpty(bn)) continue;
+ _s = _open(slice, SliceSource.SliceFileName(bn, slice, _spd));
+ if (_s != null) break;
+ }
+ if (_s == null) throw new InnoExtractException($"Could not open slice {slice}.");
+
+ // Consume+validate the 12-byte slice header.
+ var hdr = new byte[SliceSource.HeaderSize];
+ int o = 0; while (o < hdr.Length) { int g = _s.Read(hdr, o, hdr.Length - o); if (g <= 0) break; o += g; }
+ if (o < hdr.Length || !(hdr[0] == (byte)'i' && hdr[1] == (byte)'d' && hdr[2] == (byte)'s' && hdr[3] == (byte)'k' && hdr[4] == (byte)'a'))
+ throw new InnoExtractException("Bad slice magic (not a valid .bin).");
+ _slice = slice; _pos = SliceSource.HeaderSize;
+ }
+
+ // A bounded forward view of `len` bytes starting at absolute `absOffset` in `slice`.
+ public Stream Window(int slice, long absOffset, long len)
+ {
+ Ensure(slice);
+ if (absOffset < _pos)
+ throw new InnoExtractException(
+ "Non-forward .bin access. Extract files in a single ExtractFiles() call " +
+ "(offset order is handled internally), or use a seekable stream.");
+ SkipForward(absOffset - _pos);
+ return new FwdWindow(this, len);
+ }
+
+ private void SkipForward(long n)
+ {
+ if (n <= 0) return;
+ var buf = new byte[(int)Math.Min(n, 1 << 16)];
+ while (n > 0)
+ {
+ int want = (int)Math.Min(n, buf.Length);
+ int g = _s!.Read(buf, 0, want);
+ if (g <= 0) throw new EndOfStreamException("Unexpected end of .bin while skipping.");
+ n -= g; _pos += g;
+ }
+ }
+
+ private int ReadInner(byte[] b, int o, int c)
+ {
+ int g = _s!.Read(b, o, c);
+ if (g > 0) _pos += g;
+ return g;
+ }
+
+ public void Dispose() { _s?.Dispose(); _s = null; }
+
+ // Forward window; on Dispose it drains any bytes the decoder didn't consume so
+ // the parent position lands exactly at the end of this chunk span.
+ private sealed class FwdWindow : Stream
+ {
+ private readonly ForwardBinReader _r;
+ private long _left;
+ public FwdWindow(ForwardBinReader r, long len) { _r = r; _left = len; }
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_left <= 0 || count <= 0) return 0;
+ int want = (int)Math.Min(count, _left);
+ int got = _r.ReadInner(buffer, offset, want);
+ if (got > 0) _left -= got;
+ return got;
+ }
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && _left > 0) { _r.SkipForward(_left); _left = 0; }
+ base.Dispose(disposing);
+ }
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException();
+ public override void SetLength(long v) => throw new NotSupportedException();
+ public override void Write(byte[] b, int o, int c) => throw new NotSupportedException();
+ }
+ }
+
+ // Test seam: build a cross-slice chunk reader directly (used by unit tests).
+ internal static Stream OpenSliceStreamForTest(SliceOpener opener, string baseName, string baseName2,
+ uint slicesPerDisk, long firstSlice, long chunkOffset, long length)
+ => new SliceStream(new SliceSource(opener, baseName, baseName2, slicesPerDisk), firstSlice, chunkOffset, length);
+
+ internal static string SliceFileNameForTest(string baseName, long slice, uint slicesPerDisk)
+ => SliceSource.SliceFileName(baseName, slice, slicesPerDisk);
+
+ // Test seam: an embedded (in-.exe) archive over `data`, bypassing native open.
+ private InnoArchive(Stream data, ulong dataOffset)
+ {
+ _stream = data; _leaveOpen = true;
+ _readCb = ReadCallback; _inflateCb = InflateCallback;
+ _dataOffset = dataOffset; _files = new List();
+ }
+ internal static InnoArchive ForEmbeddedTest(Stream data, ulong dataOffset) => new InnoArchive(data, dataOffset);
+ internal void ExtractForTest(InnoFile f, Stream output) => ExtractTo(f, output);
+
+ // Test seam: an external archive over a SliceOpener, bypassing native open (for ExtractFiles).
+ private InnoArchive(SliceOpener opener, string baseName, uint slicesPerDisk)
+ {
+ _stream = new MemoryStream(); _leaveOpen = true;
+ _readCb = ReadCallback; _inflateCb = InflateCallback;
+ _dataOffset = 0; _sliceOpener = opener; _baseName = baseName;
+ _slicesPerDisk = slicesPerDisk; _files = new List();
+ }
+ internal static InnoArchive ForExternalTest(SliceOpener opener, string baseName, uint slicesPerDisk)
+ => new InnoArchive(opener, baseName, slicesPerDisk);
+ internal void ExtractFilesForTest(IReadOnlyCollection files, Func outputFor)
+ => ExtractFiles(files, outputFor);
+ internal static InnoFile MakeFileForTest(ulong size, InnoChecksumType ct, byte[] cksum, List parts)
+ => new InnoFile { Size = size, ChecksumType = ct, Checksum = cksum, Parts = parts };
+ internal static InnoPart MakePartForTest(ulong chunkOffset, ulong chunkSize, ulong fileSize, InnoCompression comp, InnoFilter filter, uint slice = 0)
+ => new InnoPart { ChunkOffset = chunkOffset, ChunkSize = chunkSize, FileOffset = 0, FileSize = fileSize, Compression = comp, Filter = filter, FirstSlice = slice, LastSlice = slice };
+
+ // Reads at most `limit` bytes from an underlying (forward-only) stream, then EOF.
+ // Does not dispose the underlying stream.
+ private sealed class BoundedStream : Stream
+ {
+ private readonly Stream _base;
+ private long _left;
+ public BoundedStream(Stream baseStream, long limit) { _base = baseStream; _left = limit; }
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_left <= 0 || count <= 0) return 0;
+ int want = (int)Math.Min(count, _left);
+ int got = _base.Read(buffer, offset, want);
+ if (got > 0) _left -= got;
+ return got;
+ }
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException();
+ public override void SetLength(long v) => throw new NotSupportedException();
+ public override void Write(byte[] b, int o, int c) => throw new NotSupportedException();
+ }
+
+ /// A read-only view over [start, start+length) of a shared, seekable base stream.
+ private sealed class WindowStream : Stream
+ {
+ private readonly Stream _base;
+ private readonly long _start, _length;
+ private readonly object _io;
+ private long _pos;
+
+ public WindowStream(Stream baseStream, long start, long length, object io)
+ { _base = baseStream; _start = start; _length = length; _io = io; }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_pos >= _length) return 0;
+ int want = (int)Math.Min(count, _length - _pos);
+ lock (_io)
+ {
+ _base.Seek(_start + _pos, SeekOrigin.Begin);
+ int got = _base.Read(buffer, offset, want);
+ _pos += got;
+ return got;
+ }
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => _length;
+ public override long Position { get => _pos; set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException();
+ public override void SetLength(long v) => throw new NotSupportedException();
+ public override void Write(byte[] b, int o, int c) => throw new NotSupportedException();
+ }
+ }
+}
diff --git a/Knossos.NET/Classes/Inno/InnoExeFilters.cs b/Knossos.NET/Classes/Inno/InnoExeFilters.cs
new file mode 100644
index 00000000..1ffe53a7
--- /dev/null
+++ b/Knossos.NET/Classes/Inno/InnoExeFilters.cs
@@ -0,0 +1,106 @@
+// InnoExeFilters.cs
+//
+// Managed port of innoextract's stream/exefilter.hpp. Inno Setup rewrites the
+// addresses of x86 CALL/JMP instructions in stored executables to make them
+// more compressible; these decoders reverse that transform. They operate on a
+// fully-buffered payload and always return a buffer of the SAME length as the
+// input (the transform is byte-for-byte).
+//
+// .vp data files use no filter, so for FreeSpace 2 these are rarely exercised,
+// but they are provided for completeness and correctness on any installer.
+
+using System;
+using System.IO;
+
+namespace Knossos.NET.Classes.Inno
+{
+ internal static class InnoExeFilters
+ {
+ /// Decoder for executables stored by Inno Setup < 5.2.0.
+ public static byte[] Decode4108(byte[] input)
+ {
+ var output = new byte[input.Length];
+ uint addr = 0;
+ int addrBytesLeft = 0;
+ uint addrOffset = 5; // matches the filter's initial state
+
+ for (int i = 0; i < input.Length; i++, addrOffset++)
+ {
+ int b = input[i];
+ if (addrBytesLeft == 0)
+ {
+ if (b == 0xe8 || b == 0xe9)
+ {
+ unchecked { addr = ~addrOffset + 1; } // == -addrOffset
+ addrBytesLeft = 4;
+ }
+ }
+ else
+ {
+ unchecked { addr += (byte)b; }
+ b = (byte)addr;
+ addr >>= 8;
+ addrBytesLeft--;
+ }
+ output[i] = (byte)b;
+ }
+ return output;
+ }
+
+ ///
+ /// Decoder for executables stored by Inno Setup >= 5.2.0.
+ /// selects the 5.3.9+ variant.
+ ///
+ public static byte[] Decode5200(byte[] input, bool flipHighByte)
+ {
+ const int blockSize = 0x10000;
+ using var ms = new MemoryStream(input.Length);
+ uint offset = 0; // total bytes consumed from input
+ int i = 0;
+ int n = input.Length;
+
+ while (i < n)
+ {
+ int b = input[i++];
+ ms.WriteByte((byte)b);
+ offset++;
+
+ if (b != 0xe8 && b != 0xe9) continue;
+
+ int blockSizeLeft = (int)(blockSize - ((offset - 1) % blockSize));
+ if (blockSizeLeft < 5) continue; // instruction spans a block boundary
+
+ if (i + 4 > n)
+ {
+ // EOF before the full address: emit the remaining bytes unchanged.
+ while (i < n) ms.WriteByte(input[i++]);
+ break;
+ }
+
+ byte b0 = input[i], b1 = input[i + 1], b2 = input[i + 2], b3 = input[i + 3];
+ i += 4;
+ offset += 4;
+
+ if (b3 == 0x00 || b3 == 0xff)
+ {
+ uint addr = offset & 0xffffff;
+ uint rel = (uint)(b0 | (b1 << 8) | (b2 << 16));
+ unchecked { rel -= addr; }
+ byte n0 = (byte)rel;
+ byte n1 = (byte)(rel >> 8);
+ byte n2 = (byte)(rel >> 16);
+ byte n3 = b3;
+ if (flipHighByte && (rel & 0x800000) != 0) n3 = (byte)~b3;
+ ms.WriteByte(n0); ms.WriteByte(n1); ms.WriteByte(n2); ms.WriteByte(n3);
+ }
+ else
+ {
+ // Most likely not a real CALL/JMP: leave the operand untouched.
+ ms.WriteByte(b0); ms.WriteByte(b1); ms.WriteByte(b2); ms.WriteByte(b3);
+ }
+ }
+
+ return ms.ToArray();
+ }
+ }
+}
diff --git a/Knossos.NET/Classes/Inno/InnoSharpCompat.cs b/Knossos.NET/Classes/Inno/InnoSharpCompat.cs
new file mode 100644
index 00000000..8cf5f01f
--- /dev/null
+++ b/Knossos.NET/Classes/Inno/InnoSharpCompat.cs
@@ -0,0 +1,136 @@
+// InnoSharpCompat.cs
+//
+// SharpCompress changed its API: newer versions made the public LzmaStream /
+// BZip2Stream constructors private and replaced them with static Create(...)
+// factory methods. Older versions only have the constructors.
+//
+// This shim resolves, once, whichever form is available (factory preferred,
+// constructor as fallback) so the wrapper compiles and runs against any
+// SharpCompress version. ZlibStream kept its public constructor across versions,
+// so it is used directly in InnoArchive and not handled here.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using SharpCompress.Compressors; // CompressionMode (stable public enum)
+
+namespace Knossos.NET.Classes.Inno
+{
+ internal static class InnoSharpCompat
+ {
+ private static readonly Func LzmaFactory = BuildLzma(isLzma2: false);
+ private static readonly Func Lzma2Factory = BuildLzma(isLzma2: true);
+ private static readonly Func BZip2Factory = BuildBZip2();
+
+ /// Raw LZMA1: 5-byte properties + data. outputSize may be -1 (unknown).
+ public static Stream Lzma1(byte[] props, Stream input, long inputSize, long outputSize)
+ => LzmaFactory(props, input, inputSize, outputSize);
+
+ /// Raw LZMA2: single dict-size property byte + data.
+ public static Stream Lzma2(byte[] props, Stream input, long inputSize, long outputSize)
+ => Lzma2Factory(props, input, inputSize, outputSize);
+
+ /// BZip2 decompressor (decompressConcatenated = false).
+ public static Stream BZip2(Stream input) => BZip2Factory(input);
+
+ // ------------------------------------------------------------------ //
+ private static Type Resolve(string fullName)
+ => Type.GetType($"{fullName}, SharpCompress")
+ ?? AppDomain.CurrentDomain.GetAssemblies()
+ .Select(a => a.GetType(fullName))
+ .FirstOrDefault(t => t != null)
+ ?? throw new InnoExtractException($"SharpCompress type not found: {fullName}");
+
+ // LzmaStream: prefer Create(byte[],Stream,long,long[,Stream,bool,...]),
+ // fall back to the matching public constructor.
+ private static Func BuildLzma(bool isLzma2)
+ {
+ Type t = Resolve("SharpCompress.Compressors.LZMA.LzmaStream");
+
+ if (!isLzma2)
+ {
+ // 4-arg form: (properties, inputStream, inputSize, outputSize[, optional...])
+ var fourTypes = new[] { typeof(byte[]), typeof(Stream), typeof(long), typeof(long) };
+
+ var create = t.GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .FirstOrDefault(m => m.Name == "Create" && ParamsStartWith(m, fourTypes));
+ if (create != null)
+ {
+ var pars = create.GetParameters();
+ return (p, s, isz, osz) =>
+ {
+ var args = new object?[pars.Length];
+ args[0] = p; args[1] = s; args[2] = isz; args[3] = osz;
+ for (int i = 4; i < pars.Length; i++) args[i] = pars[i].HasDefaultValue ? pars[i].DefaultValue : false;
+ return (Stream)create.Invoke(null, args)!;
+ };
+ }
+
+ var ctor = t.GetConstructor(fourTypes)
+ ?? throw new InnoExtractException("No usable LzmaStream (4-arg) API found in this SharpCompress version.");
+ return (p, s, isz, osz) => (Stream)ctor.Invoke(new object[] { p, s, isz, osz });
+ }
+ else
+ {
+ // 6-arg form: (properties, inputStream, inputSize, outputSize, presetDictionary, isLzma2)
+ var sixTypes = new[] { typeof(byte[]), typeof(Stream), typeof(long), typeof(long), typeof(Stream), typeof(bool) };
+
+ // Newer Create has an extra optional leaveOpen; match by prefix.
+ var create = t.GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .FirstOrDefault(m => m.Name == "Create" && ParamsStartWith(m, sixTypes));
+ if (create != null)
+ {
+ var pars = create.GetParameters();
+ return (p, s, isz, osz) =>
+ {
+ var args = new object?[pars.Length];
+ args[0] = p; args[1] = s; args[2] = isz; args[3] = osz; args[4] = null; args[5] = true;
+ for (int i = 6; i < pars.Length; i++) args[i] = pars[i].HasDefaultValue ? pars[i].DefaultValue : false;
+ return (Stream)create.Invoke(null, args)!;
+ };
+ }
+
+ var ctor = t.GetConstructor(sixTypes)
+ ?? throw new InnoExtractException("No usable LzmaStream (LZMA2) API found in this SharpCompress version.");
+ return (p, s, isz, osz) => (Stream)ctor.Invoke(new object?[] { p, s, isz, osz, null, true });
+ }
+ }
+
+ // BZip2Stream: prefer Create(Stream,CompressionMode,bool[,...]), else the ctor.
+ private static Func BuildBZip2()
+ {
+ Type t = Resolve("SharpCompress.Compressors.BZip2.BZip2Stream");
+ object decompress = Enum.Parse(typeof(CompressionMode), "Decompress");
+ var baseTypes = new[] { typeof(Stream), typeof(CompressionMode), typeof(bool) };
+
+ var create = t.GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .FirstOrDefault(m => m.Name == "Create" && ParamsStartWith(m, baseTypes));
+ if (create != null)
+ {
+ var pars = create.GetParameters();
+ return s =>
+ {
+ var args = new object?[pars.Length];
+ args[0] = s; args[1] = decompress; args[2] = false;
+ for (int i = 3; i < pars.Length; i++) args[i] = pars[i].HasDefaultValue ? pars[i].DefaultValue : false;
+ return (Stream)create.Invoke(null, args)!;
+ };
+ }
+
+ var ctor = t.GetConstructor(baseTypes)
+ ?? throw new InnoExtractException("No usable BZip2Stream API found in this SharpCompress version.");
+ return s => (Stream)ctor.Invoke(new object?[] { s, decompress, false });
+ }
+
+ private static bool ParamsStartWith(MethodInfo m, Type[] types)
+ {
+ var p = m.GetParameters();
+ if (p.Length < types.Length) return false;
+ for (int i = 0; i < types.Length; i++) if (p[i].ParameterType != types[i]) return false;
+ // remaining parameters (if any) must be optional
+ for (int i = types.Length; i < p.Length; i++) if (!p[i].HasDefaultValue) return false;
+ return true;
+ }
+ }
+}
diff --git a/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs b/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs
index 2e019d6d..224fa46e 100644
--- a/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs
+++ b/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs
@@ -1,17 +1,19 @@
-using Avalonia.Platform;
+using Avalonia.Controls;
+using Avalonia.Platform;
+using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
+using Knossos.NET.Classes.Inno;
using Knossos.NET.Models;
using Knossos.NET.Views;
+using SharpCompress.Archives;
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
-using System.IO;
-using Avalonia.Platform.Storage;
-using Avalonia.Controls;
namespace Knossos.NET.ViewModels
{
@@ -52,26 +54,21 @@ public partial class Fs2InstallerViewModel : ViewModelBase
internal int progressCurrent = 0;
[ObservableProperty]
internal string installText = string.Empty;
- [ObservableProperty]
- internal bool innoExtractIsAvailable = false;
private string? gogExe = null;
private KnossosWindow? window;
private int reqFilesFound = 0;
+ // GOG installer sources. Desktop: a real path. Sandboxed (Android/iOS): storage
+ // handles for the .exe and its .bin slice(s), accessed as streams (no file paths).
+ private IStorageFile? gogExeFile = null;
+ private readonly Dictionary gogBins =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
public Fs2InstallerViewModel()
{
- if(KnUtils.IsWindows || KnUtils.IsMacOS || KnUtils.IsLinux && ( KnUtils.CpuArch == "X64" || KnUtils.CpuArch == "X86" || KnUtils.CpuArch == "Arm64" || KnUtils.CpuArch == "RiscV64"))
- {
- InnoExtractIsAvailable = true;
- }
}
public Fs2InstallerViewModel(KnossosWindow window)
{
- if (KnUtils.IsWindows || KnUtils.IsMacOS || KnUtils.IsLinux && (KnUtils.CpuArch == "X64" || KnUtils.CpuArch == "X86" || KnUtils.CpuArch == "Arm64" || KnUtils.CpuArch == "RiscV64"))
- {
- InnoExtractIsAvailable = true;
- }
this.window = window;
}
@@ -82,138 +79,79 @@ internal async void InstallFS2Command()
{
if(Knossos.GetKnossosLibraryPath() == null)
{
- await MessageBox.Show(MainWindow.instance!, "The KnossosNET library path is not set, first set the library path in the settings tab before installing FS2 Retail.", "Library path is null", MessageBox.MessageBoxButtons.OK);
+ await MessageBox.Show(MainWindow.instance, "The KnossosNET library path is not set, first set the library path in the settings tab before installing FS2 Retail.", "Library path is null", MessageBox.MessageBoxButtons.OK);
return;
}
- //If gog exe first extract to a temp folder and process it first
- if(gogExe != null)
- {
- InstallText = "Running innoextract";
- IsInstalling = true;
+ await Task.Run(async () => {
try
{
- string innoPath = KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar;
- if (KnUtils.IsWindows)
- {
- innoPath += "innoextract.exe";
- }
- else
+ IsInstalling = true;
+ Dispatcher.UIThread.Invoke(new Action(() => { ProgressCurrent = 0; }));
+ var fs2Path = Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2");
+ var moviesPath = Path.Combine(fs2Path, "data", "movies");
+ var playersPath = Path.Combine(fs2Path, "data", "players");
+ Directory.CreateDirectory(fs2Path);
+ Directory.CreateDirectory(moviesPath);
+ Directory.CreateDirectory(playersPath);
+
+ //GoG (single .exe or .exe + .bin, GOG Galaxy multi-part)
+ if (gogExe != null || gogExeFile != null)
{
- if(KnUtils.IsLinux)
+ var archive = OpenGogArchive(out var cleanup);
+ try
{
- if(KnUtils.CpuArch == "X64")
- {
- innoPath += "innoextract.x64";
- }
- if (KnUtils.CpuArch == "X86")
- {
- innoPath += "innoextract.x86";
- }
- if (KnUtils.CpuArch == "Arm64")
- {
- innoPath += "innoextract.arm64";
- }
- if (KnUtils.CpuArch == "RiscV64")
+ // Gather the files we want (required + any present optional) and
+ // their destination paths, using the same routing as before.
+ var wanted = new List();
+ var destPath = new Dictionary();
+ foreach (var name in required.Concat(optional))
{
- innoPath += "innoextract.riscv64";
+ var f = archive.FindFile(name);
+ if (f == null)
+ continue;
+ var dir = RouteDir(name, fs2Path, playersPath, moviesPath);
+ if (dir == "")
+ continue;
+ wanted.Add(f);
+ destPath[f] = Path.Combine(dir, name);
}
+
+ Dispatcher.UIThread.Invoke(new Action(() => { InstallText = "Extracting FreeSpace 2 data..."; }));
+
+ // Single forward pass over the .bin (works on Android forward-only
+ // streams). ExtractFiles verifies each file's checksum internally.
+ archive.ExtractFiles(
+ wanted,
+ file => File.Create(destPath[file]),
+ file =>
+ {
+ Dispatcher.UIThread.Invoke(new Action(() =>
+ {
+ InstallText = $"Extracted: {file.Name}";
+ ProgressCurrent++;
+ }));
+ });
}
- else
+ finally
{
- if(KnUtils.IsMacOS)
- {
- innoPath += "innoextract.mac64";
- }
+ cleanup();
+ archive.Dispose();
}
}
- await Task.Run(() =>
+ //Folder Copy
+ if (filePaths.Any())
{
- var cmd = new Process();
- Directory.CreateDirectory(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog");
- cmd.StartInfo.FileName = innoPath;
- cmd.StartInfo.Arguments = gogExe + " -L -g -d \"" + KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog\"";
- cmd.StartInfo.UseShellExecute = false;
- cmd.StartInfo.CreateNoWindow = true;
- cmd.StartInfo.RedirectStandardOutput = true;
- cmd.StartInfo.StandardOutputEncoding = new UTF8Encoding(false);
- cmd.Start();
- string? output;
- while ((output = cmd.StandardOutput.ReadLine()) != null)
+ foreach (var file in filePaths)
{
- Dispatcher.UIThread.InvokeAsync(() =>
- {
- InstallText = "Running innoextract" + output;
- ProgressCurrent++;
- });
- }
- cmd.WaitForExit();
- cmd.Dispose();
- });
- /*
- there is an older gog installer that had all the data inside an /app folder, current version it just on the root
- ProccessFolder need to be pointed to the folder with all the vps and the datas folder
- */
- var sp = KnUtils.GetTopLevel().StorageProvider;
- if (File.Exists(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog" + Path.DirectorySeparatorChar + "root_fs2.vp"))
- {
- ProcessFolder( await sp.TryGetFolderFromPathAsync(Path.Combine(KnUtils.GetKnossosDataFolderPath(),"gog")) );
- }
- else
- {
- ProcessFolder(await sp.TryGetFolderFromPathAsync(Path.Combine(KnUtils.GetKnossosDataFolderPath(), "gog", "app")));
- }
- }
- catch(Exception ex)
- {
- Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.InstallFS2Command()", ex);
- return;
- }
- }
-
- if (!filePaths.Any())
- {
- await MessageBox.Show(MainWindow.instance!, "Filepaths list is empty, something happened, if you are reading a gog exe it may be because its internal folder structure is different than the expected.", "Error", MessageBox.MessageBoxButtons.OK);
- if (gogExe != null)
- {
- try
- {
- Directory.Delete(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog", true);
- }
- catch { }
- }
- return;
- }
-
- await Task.Run(async () => {
- try
- {
- IsInstalling = true;
- ProgressMax += filePaths.Count();
- ProgressCurrent = ProgressMax - filePaths.Count();
- Directory.CreateDirectory(Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "FS2" + Path.DirectorySeparatorChar + "data" + Path.DirectorySeparatorChar + "movies");
- Directory.CreateDirectory(Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "FS2" + Path.DirectorySeparatorChar + "data" + Path.DirectorySeparatorChar + "players");
- foreach (var file in filePaths)
- {
- InstallText = "Copying " + file.Name;
- if (file.Name.ToLower().Contains(".vp"))
- {
- using (var streamOrg = await file.OpenReadAsync())
- {
- using (var streamDst = new FileStream(Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2", file.Name), FileMode.Create, FileAccess.Write))
- {
- await streamOrg.CopyToAsync(streamDst);
- }
- }
- }
- else
- {
- if (file.Name.ToLower().Contains(".hcf"))
+ Dispatcher.UIThread.Invoke(new Action(() => { InstallText = $"Copying: {file.Name}"; }));
+ /* VPs */
+ if (file.Name.ToLower().Contains(".vp"))
{
using (var streamOrg = await file.OpenReadAsync())
{
- using (var streamDst = new FileStream(Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2", "data", "players", file.Name), FileMode.Create, FileAccess.Write))
+ using (var streamDst = new FileStream(Path.Combine(fs2Path, file.Name), FileMode.Create, FileAccess.Write))
{
await streamOrg.CopyToAsync(streamDst);
}
@@ -221,20 +159,37 @@ await Task.Run(async () => {
}
else
{
- using (var streamOrg = await file.OpenReadAsync())
+ /* Player Profiles */
+ if (file.Name.ToLower().Contains(".hcf"))
{
- using (var streamDst = new FileStream(Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2", "data", "movies", file.Name), FileMode.Create, FileAccess.Write))
+ using (var streamOrg = await file.OpenReadAsync())
{
- await streamOrg.CopyToAsync(streamDst);
+ using (var streamDst = new FileStream(Path.Combine(playersPath, file.Name), FileMode.Create, FileAccess.Write))
+ {
+ await streamOrg.CopyToAsync(streamDst);
+ }
+ }
+ }
+ else
+ {
+ /* Movies */
+ using (var streamOrg = await file.OpenReadAsync())
+ {
+ using (var streamDst = new FileStream(Path.Combine(moviesPath, file.Name), FileMode.Create, FileAccess.Write))
+ {
+ await streamOrg.CopyToAsync(streamDst);
+ }
}
}
}
+ Dispatcher.UIThread.Invoke(new Action(() => { ProgressCurrent++; }));
}
- ProgressCurrent++;
}
- InstallText = "Finishing tasks...";
+
+ /* FINISH */
+ Dispatcher.UIThread.Invoke(new Action(() => { ProgressCurrent = ProgressMax; InstallText = "Finishing tasks..."; }));
var fs2Mod = new Mod();
- fs2Mod.fullPath = Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "FS2";
+ fs2Mod.fullPath = fs2Path;
fs2Mod.folderName = "FS2";
fs2Mod.installed = true;
fs2Mod.id = "FS2";
@@ -339,146 +294,181 @@ await Task.Run(async () => {
}
}
catch { }
- InstallText = "Install Complete!, KnossosNET is reloading the library...";
+ Dispatcher.UIThread.Invoke(new Action(() => { InstallText = "Install Complete!, KnossosNET is reloading the library..."; }));
Knossos.ResetBasePath();
- if(gogExe != null)
- {
- try
- {
- Directory.Delete(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog",true);
- }
- catch { }
- }
+ await Task.Delay(3000);
+ Dispatcher.UIThread.Invoke(new Action(() => { window?.Close(); }));
}
catch(Exception ex)
{
Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.InstallFS2Command()",ex);
+ Dispatcher.UIThread.Invoke(new Action(() => { MessageBox.Show(MainWindow.instance, $"An error has ocurred during file copy: {ex.Message}.", "An error has ocurred", MessageBox.MessageBoxButtons.OK); }));
}
});
}
///
/// Open file dialog to select a gog exe file, it checks that this is a valid FS2 install file
+ /// by counting the number of requiered and optional files present in it.
///
internal async void LoadGoGExeCommand()
{
- FilePickerOpenOptions options = new FilePickerOpenOptions();
- options.AllowMultiple = false;
- options.Title = "Select your Freespace 2 gog .exe installer file";
-
- var topmostWindow = KnUtils.GetTopLevel();
- var result = await topmostWindow.StorageProvider.OpenFilePickerAsync(options);
-
- if (result != null && result.Count > 0)
+ var top = KnUtils.GetTopLevel();
+ // Sandboxed platforms (Android/Browser) have no usable file paths, so we
+ // pick the FOLDER (which grants access to both the .exe and its .bin siblings).
+ // Desktop keeps the familiar .exe file picker (path-based).
+ bool sandboxed = KnUtils.IsAndroid || KnUtils.IsBrowser;
+ try
{
CanInstall = false;
gogExe = null;
- try
+ gogExeFile = null;
+ gogBins.Clear();
+
+ if (!sandboxed)
{
- string innoPath = KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar;
+ var options = new FilePickerOpenOptions();
+ options.AllowMultiple = false;
+ options.Title = "Select your Freespace 2 gog .exe installer file";
+ var result = await top.StorageProvider.OpenFilePickerAsync(options);
+ if (result == null || result.Count == 0)
+ return;
+ // If the platform gives us a real path, use it; otherwise fall back to folder mode.
+ var localPath = result[0].TryGetLocalPath();
+ if (localPath != null)
+ gogExe = localPath;
+ else
+ sandboxed = true;
+ }
- /*Copy Innoextract License file*/
- using (var fileStream = File.Create(innoPath + Path.DirectorySeparatorChar + "innoextract.license"))
+ if (sandboxed && gogExe == null)
+ {
+ var fopts = new FolderPickerOpenOptions();
+ fopts.AllowMultiple = false;
+ fopts.Title = "Select the folder with your Freespace 2 gog installer (.exe + .bin)";
+ var folders = await top.StorageProvider.OpenFolderPickerAsync(fopts);
+ if (folders == null || folders.Count == 0)
+ return;
+ await FindGogFilesInFolder(folders[0]);
+ if (gogExeFile == null)
{
- AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/innoextract.license")).CopyTo(fileStream);
- fileStream.Close();
+ await MessageBox.Show(MainWindow.instance, "No .exe installer was found in that folder.", "Installer not found", MessageBox.MessageBoxButtons.OK);
+ return;
}
+ }
- if (KnUtils.IsWindows)
+ // Validate on a background thread: opening storage streams blocks, and doing
+ // that on the UI thread can deadlock on Android.
+ int total = 0;
+ bool allRequired = false;
+ await Task.Run(() =>
+ {
+ var archive = OpenGogArchive(out var cleanup);
+ try
{
- innoPath += "innoextract.exe";
- using (var fileStream = File.Create(innoPath))
- {
- AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/win/innoextract.exe")).CopyTo(fileStream);
- fileStream.Close();
- }
+ int req = required.Count(r => archive.FindFile(r) != null);
+ allRequired = req == required.Length;
+ total = req + optional.Count(o => archive.FindFile(o) != null);
}
- else
+ finally
{
- if (KnUtils.IsLinux)
- {
- if (KnUtils.CpuArch == "X64")
- {
- innoPath += "innoextract.x64";
- using (var fileStream = File.Create(innoPath))
- {
- AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64")).CopyTo(fileStream);
- fileStream.Close();
- KnUtils.Chmod(innoPath,"+x");
- }
- }
- if (KnUtils.CpuArch == "Arm64")
- {
- innoPath += "innoextract.arm64";
- using (var fileStream = File.Create(innoPath))
- {
- AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64")).CopyTo(fileStream);
- fileStream.Close();
- KnUtils.Chmod(innoPath, "+x");
- }
- }
- if (KnUtils.CpuArch == "RiscV64")
- {
- innoPath += "innoextract.riscv64";
- using (var fileStream = File.Create(innoPath))
- {
- AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract.riscv64")).CopyTo(fileStream);
- fileStream.Close();
- KnUtils.Chmod(innoPath, "+x");
- }
- }
- }
- else
- {
- if(KnUtils.IsMacOS)
- {
- innoPath += "innoextract.mac64";
- using (var fileStream = File.Create(innoPath))
- {
- AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64")).CopyTo(fileStream);
- fileStream.Close();
- KnUtils.Chmod(innoPath, "+x");
- }
- }
- }
+ cleanup();
+ archive.Dispose();
}
+ });
- var cmd = new Process();
- var file = new FileInfo(result[0].Path.LocalPath.ToString());
- gogExe = "\"" + file.FullName + "\"";
- cmd.StartInfo.FileName = innoPath;
- cmd.StartInfo.Arguments = gogExe + " -l -g";
- cmd.StartInfo.UseShellExecute = false;
- cmd.StartInfo.CreateNoWindow = true;
- cmd.StartInfo.RedirectStandardOutput = true;
- cmd.StartInfo.RedirectStandardInput = true;
- cmd.StartInfo.StandardOutputEncoding = new UTF8Encoding(false);
- cmd.Start();
- var innoOutput = cmd.StandardOutput.ReadToEnd().ToLower();
- cmd.WaitForExit();
- cmd.Dispose();
- int count = 0;
- foreach (var reqFileName in required)
- {
- if (innoOutput.Contains(reqFileName))
- count++;
- }
+ if (!allRequired)
+ {
+ gogExe = null;
+ gogExeFile = null;
+ gogBins.Clear();
+ await MessageBox.Show(MainWindow.instance, "Unable to find all the required Freespace 2 files in gog installer.", "Files not found", MessageBox.MessageBoxButtons.OK);
+ return;
+ }
+ ProgressMax = total;
+ CanInstall = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.LoadGoGExeCommand()", ex);
+ gogExe = null;
+ gogExeFile = null;
+ gogBins.Clear();
+ }
+ }
- if (count != required.Count())
- {
- //Missing files
- gogExe = null;
- await MessageBox.Show(MainWindow.instance!, "Unable to find all the required Freespace 2 files in gog exe.", "Files not found", MessageBox.MessageBoxButtons.OK);
- return;
- }
- ProgressMax = innoOutput.Split('\n').Length-2;
- ProgressMax += required.Count() + optional.Count();
- CanInstall = true;
- }catch(Exception ex)
+ ///
+ /// Finds the GOG installer .exe and its .bin slice(s) inside a picked folder,
+ /// storing them as storage handles (streams are opened later, at install time).
+ ///
+ private async Task FindGogFilesInFolder(IStorageFolder folder)
+ {
+ IStorageFile? anyExe = null;
+ await foreach (var item in folder.GetItemsAsync())
+ {
+ if (item is not IStorageFile f)
+ continue;
+ var lname = f.Name.ToLowerInvariant();
+ if (lname.EndsWith(".exe"))
{
- Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.LoadGoGExeCommand()", ex);
+ anyExe ??= f;
+ if (lname.Contains("setup"))
+ gogExeFile ??= f; // prefer a setup*.exe
+ }
+ else if (lname.EndsWith(".bin"))
+ {
+ gogBins[f.Name] = f;
}
}
+ gogExeFile ??= anyExe;
+ }
+
+ ///
+ /// Opens the selected GOG installer as an InnoArchive, from a path (desktop) or from
+ /// storage streams (sandboxed). The returned cleanup action releases opened streams.
+ ///
+ private InnoArchive OpenGogArchive(out Action cleanup)
+ {
+ if (gogExe != null)
+ {
+ cleanup = () => { };
+ return new InnoArchive(gogExe);
+ }
+ if (gogExeFile != null)
+ {
+ var opened = new List();
+ InnoArchive.SliceOpener opener = (idx, expectedName) =>
+ {
+ if (gogBins.TryGetValue(expectedName, out var bf))
+ {
+ var bs = bf.OpenReadAsync().GetAwaiter().GetResult();
+ opened.Add(bs);
+ return bs;
+ }
+ return null;
+ };
+ var exeStream = gogExeFile.OpenReadAsync().GetAwaiter().GetResult();
+ var archive = new InnoArchive(exeStream, gogExeFile.Name, opener, leaveOpen: false);
+ cleanup = () => { foreach (var st in opened) { try { st.Dispose(); } catch { } } };
+ return archive;
+ }
+ throw new InvalidOperationException("No GOG installer selected.");
+ }
+
+ ///
+ /// Destination directory for a file name (same routing as the folder-copy path).
+ ///
+ private static string RouteDir(string name, string fs2Path, string playersPath, string moviesPath)
+ {
+ switch (Path.GetExtension(name).ToLowerInvariant())
+ {
+ case ".vp":
+ case ".vpc": return fs2Path;
+ case ".hcf": return playersPath;
+ case ".ogg":
+ case ".mve": return moviesPath;
+ default: return "";
+ }
}
///
@@ -544,12 +534,14 @@ private async void ProcessFolder(IStorageFolder? path, bool topLevel = true)
if (reqFilesFound < 9)
{
//Missing files
- await MessageBox.Show(MainWindow.instance!, "Unable to find all the required Freespace 2 files in this directory.", "Files not found", MessageBox.MessageBoxButtons.OK);
+ await MessageBox.Show(MainWindow.instance, "Unable to find all the required Freespace 2 files in this directory.", "Files not found", MessageBox.MessageBoxButtons.OK);
return;
}
CanInstall = true;
+ ProgressMax = filePaths.Count();
}
- }catch (Exception ex)
+ }
+ catch (Exception ex)
{
Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.ProcessFolder()", ex);
}
diff --git a/Knossos.NET/Views/Windows/Fs2InstallerView.axaml b/Knossos.NET/Views/Windows/Fs2InstallerView.axaml
index 1b03e0f6..4cc4908f 100644
--- a/Knossos.NET/Views/Windows/Fs2InstallerView.axaml
+++ b/Knossos.NET/Views/Windows/Fs2InstallerView.axaml
@@ -32,7 +32,7 @@
-
+