diff --git a/.gitignore b/.gitignore
index 0cd8a7b2..1a0c779a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,10 +21,6 @@ mono_crash.*
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
-x64/
-[Ww][Ii][Nn]32/
-[Aa][Rr][Mm]/
-[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bb225235..506bd857 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,6 +14,7 @@
+
@@ -23,7 +24,7 @@
-
+
\ No newline at end of file
diff --git a/Knossos.NET.Android/natives/3rdparty/etc2comp/AUTHORS b/Knossos.NET.Android/natives/3rdparty/etc2comp/AUTHORS
new file mode 100644
index 00000000..e78a7f4d
--- /dev/null
+++ b/Knossos.NET.Android/natives/3rdparty/etc2comp/AUTHORS
@@ -0,0 +1,7 @@
+# This is the list of Etc2Comp authors for copyright purposes.
+#
+# This does not necessarily list everyone who has contributed code, since in
+# some cases, their employer may be the copyright holder. To see the full list
+# of contributors, see the revision history in source control.
+Google Inc.
+Blue Shift Inc.
diff --git a/Knossos.NET.Android/natives/3rdparty/etc2comp/LICENSE b/Knossos.NET.Android/natives/3rdparty/etc2comp/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/Knossos.NET.Android/natives/3rdparty/etc2comp/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
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/libetc2native.so b/Knossos.NET.Android/natives/arm64-v8a/libetc2native.so
new file mode 100644
index 00000000..36cbafe5
Binary files /dev/null and b/Knossos.NET.Android/natives/arm64-v8a/libetc2native.so differ
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/libetc2native.so b/Knossos.NET.Android/natives/armeabi-v7a/libetc2native.so
new file mode 100644
index 00000000..aaf8c82a
Binary files /dev/null and b/Knossos.NET.Android/natives/armeabi-v7a/libetc2native.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/libetc2native.so b/Knossos.NET.Android/natives/x86/libetc2native.so
new file mode 100644
index 00000000..83379f27
Binary files /dev/null and b/Knossos.NET.Android/natives/x86/libetc2native.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/libetc2native.so b/Knossos.NET.Android/natives/x86_64/libetc2native.so
new file mode 100644
index 00000000..0cc52b66
Binary files /dev/null and b/Knossos.NET.Android/natives/x86_64/libetc2native.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..6c851303 100644
--- a/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
+++ b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
@@ -11,6 +11,7 @@
copyusedtruetrue
+ AnyCPU;x64
@@ -27,27 +28,136 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_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
+ etc2native.dll
+
+
+ PreserveNewest
+ innowrap.dll
+
+
+
+
+ PreserveNewest
+ etc2native.dll
+
+
+ PreserveNewest
+ innowrap.dll
+
+
+
+
+ PreserveNewest
+ etc2native.dll
+
+
+ PreserveNewest
+ innowrap.dll
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.so
+
+
+ PreserveNewest
+ libinnowrap.so
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.so
+
+
+ PreserveNewest
+ libinnowrap.so
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.so
+
+
+ PreserveNewest
+ libinnowrap.so
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.dylib
+
+
+ PreserveNewest
+ libinnowrap.dylib
+
+
+
diff --git a/Knossos.NET.Desktop/precompiled/3rdparty/etc2comp/AUTHORS b/Knossos.NET.Desktop/precompiled/3rdparty/etc2comp/AUTHORS
new file mode 100644
index 00000000..e78a7f4d
--- /dev/null
+++ b/Knossos.NET.Desktop/precompiled/3rdparty/etc2comp/AUTHORS
@@ -0,0 +1,7 @@
+# This is the list of Etc2Comp authors for copyright purposes.
+#
+# This does not necessarily list everyone who has contributed code, since in
+# some cases, their employer may be the copyright holder. To see the full list
+# of contributors, see the revision history in source control.
+Google Inc.
+Blue Shift Inc.
diff --git a/Knossos.NET.Desktop/precompiled/3rdparty/etc2comp/LICENSE b/Knossos.NET.Desktop/precompiled/3rdparty/etc2comp/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/Knossos.NET.Desktop/precompiled/3rdparty/etc2comp/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
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/libetc2native.so b/Knossos.NET.Desktop/precompiled/linux/arm64/libetc2native.so
new file mode 100644
index 00000000..73636437
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/arm64/libetc2native.so differ
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/libetc2native.so b/Knossos.NET.Desktop/precompiled/linux/riscv64/libetc2native.so
new file mode 100644
index 00000000..d6ac5bc5
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/riscv64/libetc2native.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/libetc2native.so b/Knossos.NET.Desktop/precompiled/linux/x64/libetc2native.so
new file mode 100644
index 00000000..2e5b0d63
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/x64/libetc2native.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/etc2native.dll b/Knossos.NET.Desktop/precompiled/windows/arm64/etc2native.dll
new file mode 100644
index 00000000..965ac794
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/arm64/etc2native.dll 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/etc2native.dll b/Knossos.NET.Desktop/precompiled/windows/x64/etc2native.dll
new file mode 100644
index 00000000..beb431fa
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/x64/etc2native.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/etc2native.dll b/Knossos.NET.Desktop/precompiled/windows/x86/etc2native.dll
new file mode 100644
index 00000000..22437a77
Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/x86/etc2native.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/AndroidHelper.cs b/Knossos.NET/Classes/AndroidHelper.cs
index 6413df55..67b2e6c0 100644
--- a/Knossos.NET/Classes/AndroidHelper.cs
+++ b/Knossos.NET/Classes/AndroidHelper.cs
@@ -2,6 +2,7 @@
using Android.App;
using System.Linq;
using Android.Content;
+using Android.Opengl;
#endif
using Avalonia.Threading;
using System;
@@ -183,6 +184,56 @@ public static void LaunchFSO(string engineLibPath, string? workingFolder, string
}
}
+ static (bool s3tc, bool bc7, bool read) _gpuSupportOpenGL = (false, false, false);
+ ///
+ /// Creates a OpenGL ES context to check if the GPU supports S3TC and BPTC extensions
+ ///
+ /// true/false
+ public static (bool s3tc, bool bc7, bool read) GpuSupportsBCnTexturesOpenGL()
+ {
+ if(_gpuSupportOpenGL.read)
+ return _gpuSupportOpenGL;
+ try
+ {
+ var display = EGL14.EglGetDisplay(EGL14.EglDefaultDisplay);
+ EGL14.EglInitialize(display, new int[2], 0, new int[2], 1);
+
+ int[] cfgAttribs = {
+ EGL14.EglRenderableType, EGL14.EglOpenglEs2Bit,
+ EGL14.EglSurfaceType, EGL14.EglPbufferBit,
+ EGL14.EglNone
+ };
+ var configs = new EGLConfig[1];
+ EGL14.EglChooseConfig(display, cfgAttribs, 0, configs, 0, 1, new int[1], 0);
+
+ int[] ctxAttribs = { EGL14.EglContextClientVersion, 2, EGL14.EglNone };
+ var ctx = EGL14.EglCreateContext(display, configs[0], EGL14.EglNoContext, ctxAttribs, 0);
+
+ int[] pbAttribs = { EGL14.EglWidth, 1, EGL14.EglHeight, 1, EGL14.EglNone };
+ var surf = EGL14.EglCreatePbufferSurface(display, configs[0], pbAttribs, 0);
+
+ EGL14.EglMakeCurrent(display, surf, surf, ctx);
+
+ string ext = GLES20.GlGetString(GLES20.GlExtensions) ?? "";
+ _gpuSupportOpenGL.s3tc = ext.Contains("GL_EXT_texture_compression_s3tc");
+ _gpuSupportOpenGL.bc7 = ext.Contains("GL_EXT_texture_compression_bptc");
+ _gpuSupportOpenGL.read = true;
+ Log.Add(Log.LogSeverity.Information, "AndroidHelper.GpuSupportsBCnTexturesOpenGL()", $"S3TC Support: {_gpuSupportOpenGL.s3tc}");
+ Log.Add(Log.LogSeverity.Information, "AndroidHelper.GpuSupportsBCnTexturesOpenGL()", $"BC7 Support: {_gpuSupportOpenGL.bc7}");
+
+ // cleanup
+ EGL14.EglMakeCurrent(display, EGL14.EglNoSurface, EGL14.EglNoSurface, EGL14.EglNoContext);
+ EGL14.EglDestroySurface(display, surf);
+ EGL14.EglDestroyContext(display, ctx);
+ EGL14.EglTerminate(display);
+ }
+ catch(Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "AndroidHelper.GpuSupportsBCnTexturesOpenGL()", ex);
+ _gpuSupportOpenGL = (false, false, true);
+ }
+ return _gpuSupportOpenGL;
+ }
#else
//Stubs
public static string? GetExternalAppFilesDir() => "";
@@ -192,6 +243,7 @@ public static void LaunchFSO(string engineLibPath, string? workingFolder, string
public static string GetDefaultKnetDir() => "";
public static string GetDefaultKnetDataDir() => "";
public static string GetDefaultFSODataDir() => "";
+ public static (bool s3tc, bool bc7, bool read) GpuSupportsBCnTexturesOpenGL() => (true, true, true);
public static void LaunchFSO(string engineLibPath, string? workingFolder, string cmdline) { }
#endif
}
diff --git a/Knossos.NET/Classes/EffHelper.cs b/Knossos.NET/Classes/EffHelper.cs
new file mode 100644
index 00000000..d1a1b850
--- /dev/null
+++ b/Knossos.NET/Classes/EffHelper.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using VP.NET;
+
+namespace Knossos.NET.Classes
+{
+ ///
+ /// Reads an .eff animation manifest and (when needed) rewrites its $Type line.
+ ///
+ /// An .eff is a plain text manifest:
+ /// $Type: dds (frame image format: dds / ktx / png / pcx / tga / jpg / ...)
+ /// $Frames: 90 (frame count)
+ /// $FPS: 30
+ /// Frames are named "{effBaseName}_{i:D4}.{type}", e.g. Particle_Wave_Blue_1_0000.dds.
+ /// Real files use CRLF and may carry trailing spaces, so everything is trimmed when parsed.
+ ///
+ public sealed class EffHelper
+ {
+ public string Type { get; private set; } = ""; // lower, no leading dot
+ public int FrameCount { get; private set; }
+ public int Fps { get; private set; }
+ public string BaseName { get; private set; } = ""; // original case, no extension
+
+ public bool IsDds => Type.Equals("dds", StringComparison.OrdinalIgnoreCase);
+
+ /// Theoretical frame file names for this eff using the given extension (no dot), in order.
+ public IEnumerable FrameNames(string ext)
+ {
+ for (int i = 0; i < FrameCount; i++)
+ yield return $"{BaseName}_{i:D4}.{ext}";
+ }
+
+ // parsing
+
+ public static EffHelper Parse(string text, string effFileName)
+ {
+ var eff = new EffHelper();
+ using var reader = new StringReader(text);
+ string? line;
+ while ((line = reader.ReadLine()) != null)
+ {
+ line = line.Trim();
+ if (line.Length == 0 || !line.StartsWith("$")) continue;
+ int colon = line.IndexOf(':');
+ if (colon < 0) continue;
+ string key = line.Substring(1, colon - 1).Trim();
+ string value = line.Substring(colon + 1).Trim();
+ switch (key.ToLowerInvariant())
+ {
+ case "type": eff.Type = value.TrimStart('.').ToLowerInvariant(); break;
+ case "frames": int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var f); eff.FrameCount = f; break;
+ case "fps": int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p); eff.Fps = p; break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(eff.Type))
+ throw new InvalidDataException($"'{effFileName}': missing $Type");
+ if (eff.FrameCount <= 0)
+ throw new InvalidDataException($"'{effFileName}': invalid $Frames ({eff.FrameCount})");
+
+ eff.BaseName = Path.GetFileNameWithoutExtension(effFileName);
+ return eff;
+ }
+
+ public static async Task ParseAsync(Stream stream, string effFileName)
+ {
+ stream.Position = 0;
+ using var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, leaveOpen: true);
+ var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+ return Parse(text, effFileName);
+ }
+
+ // $Type patching
+
+ ///
+ /// Returns the eff text with only the $Type value rewritten to .
+ /// $Frames / $FPS / comments / line endings / indentation are preserved.
+ /// Returns null if the eff already has that type (idempotent: safe to run on every pass).
+ ///
+ public static string? PatchTypeText(string text, string newType)
+ {
+ string nl = text.Contains("\r\n") ? "\r\n" : (text.Contains("\n") ? "\n" : Environment.NewLine);
+ var lines = text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
+
+ bool changed = false;
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var trimmed = lines[i].TrimStart();
+ if (!trimmed.StartsWith("$")) continue;
+ int colon = trimmed.IndexOf(':');
+ if (colon < 0) continue;
+ var key = trimmed.Substring(1, colon - 1).Trim();
+ if (!key.Equals("Type", StringComparison.OrdinalIgnoreCase)) continue;
+
+ var current = trimmed.Substring(colon + 1).Trim().TrimStart('.').ToLowerInvariant();
+ if (current == newType.ToLowerInvariant()) return null; // already correct
+
+ int indent = lines[i].Length - lines[i].TrimStart().Length;
+ lines[i] = lines[i].Substring(0, indent) + "$Type: " + newType;
+ changed = true;
+ break;
+ }
+ return changed ? string.Join(nl, lines) : null;
+ }
+
+ /// Patch a loose eff on disk. Returns true if the file was modified.
+ public static async Task PatchLooseAsync(string effPath, string newType, CancellationToken token = default)
+ {
+ var original = await File.ReadAllTextAsync(effPath, token).ConfigureAwait(false);
+ var patched = PatchTypeText(original, newType);
+ if (patched == null) return false;
+ await File.WriteAllTextAsync(effPath, patched, new UTF8Encoding(false), token).ConfigureAwait(false);
+ return true;
+ }
+
+ ///
+ /// Patch an eff stored inside a vp: replaces the node with a patched copy. The caller is
+ /// responsible for saving the vp afterwards. Returns true if a replacement was queued.
+ /// A unique temp subfolder is used, and the temp file keeps the node's ORIGINAL-CASE name so
+ /// VPFile.AddFile (which matches names case-sensitively) replaces the entry instead of duplicating it.
+ ///
+ public static async Task PatchInVpAsync(VPFile effNode, string newType, string workFolder, CancellationToken token = default)
+ {
+ if (effNode.parent == null) return false;
+
+ using var ms = new MemoryStream();
+ await effNode.ReadToStream(ms).ConfigureAwait(false);
+ ms.Position = 0;
+ string original;
+ using (var reader = new StreamReader(ms, Encoding.UTF8, true, 1024, leaveOpen: true))
+ original = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+ var patched = PatchTypeText(original, newType);
+ if (patched == null) return false;
+
+ var dir = Path.Combine(workFolder, "effpatch_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ var tmp = Path.Combine(dir, effNode.info.name);
+ await File.WriteAllTextAsync(tmp, patched, new UTF8Encoding(false), token).ConfigureAwait(false);
+
+ effNode.parent.AddFile(new FileInfo(tmp));
+ return true;
+ }
+ }
+}
diff --git a/Knossos.NET/Classes/Etc2Transcoder.cs b/Knossos.NET/Classes/Etc2Transcoder.cs
new file mode 100644
index 00000000..6206d365
--- /dev/null
+++ b/Knossos.NET/Classes/Etc2Transcoder.cs
@@ -0,0 +1,487 @@
+// Etc2Transcoder.cs
+// DDS (BC1/BC2/BC3/BC7 or uncompressed) -> KTX1 (ETC2), stream based.
+// decode : BCnEncoder.NET (managed, no native deps) -> RGBA8
+// resize : optional 50% box filter (managed)
+// encode : etc2native.dll (etc2comp fork) RGBA8 -> ETC2
+// wrap : KTX1 written here in C#
+//
+// NuGet: BCnEncoder.Net (>= 2.2.1)
+// Native: etc2native.dll / libetc2native.so next to the app (or runtimes//native)
+//
+// Mapping (forceRgba8 == false):
+// DXT1 (no alpha) -> ETC2_RGB
+// DXT1 (alpha) -> ETC2_RGBA1
+// DXT3/DXT5/BC7 -> ETC2_RGBA8
+// uncompressed -> ETC2_RGBA8 (only if forceTranscodeUncompressed)
+// forceRgba8 == true forces ETC2_RGBA8 for everything.
+// forceResize halves W/H (and regenerates the mip chain when the source had one).
+
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using BCnEncoder.Decoder;
+using BCnEncoder.Shared;
+
+namespace Etc2
+{
+ public enum Etc2Status
+ {
+ Transcoded = 0,
+ NotCompressed = 1, // uncompressed source, left untouched
+ Skipped = 2, // hardware already supports this texture's format; left untouched
+ ErrorInput = -1, // not a DDS / truncated
+ ErrorUnhandled = -2, // a format we don't handle
+ ErrorEncode = -3, // native ETC2 encode failed
+ }
+
+ /// ETC2 target, matching etc2native's format ints and the KTX GL enums.
+ internal enum Etc2Format { Rgb = 0, Rgba1 = 1, Rgba8 = 2 }
+
+ public static class Etc2Transcoder
+ {
+ private const string Lib = "etc2native";
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern int etc2_encode_rgba8(
+ byte[] rgba, int width, int height, int format, float effort, int errMetric, int jobs,
+ out IntPtr outBits, out int outBytes, out int extW, out int extH);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern void etc2_free(IntPtr p);
+
+ [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr etc2_version();
+
+ /// Returns the native library version string (confirms which etc2native is loaded).
+ public static string NativeVersion()
+ {
+ var p = etc2_version();
+ return p == IntPtr.Zero ? "" : (Marshal.PtrToStringAnsi(p) ?? "");
+ }
+
+ // ---- GL enums for the KTX1 header ----
+ private const uint GL_RGB = 0x1907, GL_RGBA = 0x1908;
+ private const uint GL_ETC2_RGB = 0x9274, GL_ETC2_RGBA1 = 0x9276, GL_ETC2_RGBA8 = 0x9278;
+
+ /// etc2comp effort 0..100 (higher = better/slower).
+ /// encoder worker threads (1 = single; you can also call this from many C# threads).
+ public static Etc2Status Transcode(Stream input, Stream output,
+ bool forceRgba8 = false, bool forceResize = false, bool forceTranscodeUncompressed = false,
+ bool hasS3TC = false, bool hasBC7 = false, float quality = 60f, int jobs = 1)
+ {
+ if (input == null) throw new ArgumentNullException(nameof(input));
+ if (output == null) throw new ArgumentNullException(nameof(output));
+ byte[] dds = ReadAll(input);
+ return TranscodeBytes(dds, output, forceRgba8, forceResize, forceTranscodeUncompressed, hasS3TC, hasBC7, quality, jobs);
+ }
+
+ public static Etc2Status TranscodeBytes(byte[] dds, Stream output,
+ bool forceRgba8 = false, bool forceResize = false, bool forceTranscodeUncompressed = false,
+ bool hasS3TC = false, bool hasBC7 = false, float quality = 60f, int jobs = 1)
+ {
+ if (!Dds.Parse(dds, out var s)) return Etc2Status.ErrorInput;
+ if (!s.Handled) return Etc2Status.ErrorUnhandled;
+
+ // Hardware-capability filter: a compressed texture only needs transcoding when
+ // the device can't read its format natively. S3TC covers DXT1/DXT3/DXT5 (BC1/2/3);
+ // BC7 is BPTC. forceRgba8 overrides everything (transcode all to RGBA8).
+ if (!forceRgba8 && s.Compressed)
+ {
+ bool deviceHasIt = (s.CmpFormat == Dds.Bc.Bc7) ? hasBC7 : hasS3TC;
+ if (deviceHasIt) return Etc2Status.Skipped;
+ }
+
+ if (!s.Compressed && !(forceRgba8 || forceTranscodeUncompressed)) return Etc2Status.NotCompressed;
+
+ // Decide target format.
+ Etc2Format fmt;
+ if (forceRgba8 || !s.Compressed) fmt = Etc2Format.Rgba8;
+ else if (s.CmpFormat == Dds.Bc.Dxt1)
+ fmt = Dds.Dxt1UsesAlpha(dds, s.DataOffset, s.Width, s.Height) ? Etc2Format.Rgba1 : Etc2Format.Rgb;
+ else fmt = Etc2Format.Rgba8; // DXT3/DXT5/BC7
+
+ // leaveOpen:true => we never close/own the caller's stream.
+ using var bw = new BinaryWriter(output, System.Text.Encoding.UTF8, leaveOpen: true);
+
+ if (forceResize)
+ {
+ int newW = Math.Max(1, s.Width / 2), newH = Math.Max(1, s.Height / 2);
+ int outMips = s.Mips > 1 ? MipCount(newW, newH) : 1;
+ WriteKtxHeader(bw, fmt, newW, newH, outMips);
+
+ byte[] full = LevelToRgba(dds, s, 0); // base, full res
+ byte[] rgba = HalfBox(full, s.Width, s.Height, newW, newH);
+ int cw = newW, ch = newH;
+ for (int lv = 0; lv < outMips; lv++)
+ {
+ if (!EncodeAppend(bw, rgba, cw, ch, fmt, quality, jobs)) return Etc2Status.ErrorEncode;
+ if (lv + 1 < outMips)
+ {
+ int nw = Math.Max(1, cw / 2), nh = Math.Max(1, ch / 2);
+ rgba = HalfBox(rgba, cw, ch, nw, nh); cw = nw; ch = nh;
+ }
+ }
+ }
+ else
+ {
+ WriteKtxHeader(bw, fmt, s.Width, s.Height, s.Mips);
+ for (int lv = 0; lv < s.Mips; lv++)
+ {
+ int lw = Math.Max(1, s.Width >> lv), lh = Math.Max(1, s.Height >> lv);
+ byte[] rgba = LevelToRgba(dds, s, lv);
+ if (!EncodeAppend(bw, rgba, lw, lh, fmt, quality, jobs)) return Etc2Status.ErrorEncode;
+ }
+ }
+ bw.Flush();
+ return Etc2Status.Transcoded;
+ }
+
+ public static Task TranscodeAsync(Stream input, Stream output,
+ bool forceRgba8 = false, bool forceResize = false, bool forceTranscodeUncompressed = false,
+ bool hasS3TC = false, bool hasBC7 = false, float quality = 60f, int jobs = 1, CancellationToken ct = default)
+ => Task.Run(() => Transcode(input, output, forceRgba8, forceResize, forceTranscodeUncompressed, hasS3TC, hasBC7, quality, jobs), ct);
+
+ ///
+ /// Transcode DDS bytes and return the KTX1 bytes, or null if the source was left
+ /// untouched (NotCompressed). Throws InvalidDataException on a hard error.
+ /// No streams to manage — ideal for parallel batch work.
+ /// Returns (et2status, byte[]?)
+ ///
+ public static (Etc2Status etc2status, byte[]? bytes) TranscodeToKtxBytes(byte[] dds,
+ bool forceRgba8 = false, bool forceResize = false, bool forceTranscodeUncompressed = false,
+ bool hasS3TC = false, bool hasBC7 = false, float quality = 60f, int jobs = 1)
+ {
+ using var ms = new MemoryStream();
+ var st = TranscodeBytes(dds, ms, forceRgba8, forceResize, forceTranscodeUncompressed, hasS3TC, hasBC7, quality, jobs);
+
+ if (st != Etc2Status.Transcoded && st != Etc2Status.NotCompressed && st != Etc2Status.Skipped)
+ throw new InvalidDataException($"DDS->KTX transcode failed: {st}");
+
+ return (st, ms.ToArray());
+ }
+
+ ///
+ /// Like TranscodeToKtxBytes but also reports the exact status, so callers can tell
+ /// NotCompressed vs Skipped vs Transcoded without ambiguity (no bogus log entries).
+ /// On Transcoded, holds the bytes; otherwise it is null.
+ /// Throws InvalidDataException only on a hard error.
+ ///
+ public static Etc2Status TryTranscode(byte[] dds, out byte[]? ktx,
+ bool forceRgba8 = false, bool forceResize = false, bool forceTranscodeUncompressed = false,
+ bool hasS3TC = false, bool hasBC7 = false, float quality = 60f, int jobs = 1)
+ {
+ ktx = null;
+ using var ms = new MemoryStream();
+ var st = TranscodeBytes(dds, ms, forceRgba8, forceResize, forceTranscodeUncompressed, hasS3TC, hasBC7, quality, jobs);
+ switch (st)
+ {
+ case Etc2Status.Transcoded: ktx = ms.ToArray(); return st;
+ case Etc2Status.NotCompressed:
+ case Etc2Status.Skipped: return st;
+ default: throw new InvalidDataException($"DDS->KTX transcode failed: {st}");
+ }
+ }
+
+ ///
+ /// File-to-file. Reads the input fully (releasing its handle immediately), encodes in
+ /// memory, and writes the output ONLY on success — so failed/uncompressed inputs never
+ /// create or lock an output file. Safe to call from many threads on distinct paths.
+ ///
+ public static Etc2Status TranscodeFile(string inputPath, string outputPath,
+ bool forceRgba8 = false, bool forceResize = false, bool forceTranscodeUncompressed = false,
+ bool hasS3TC = false, bool hasBC7 = false, float quality = 60f, int jobs = 1)
+ {
+ byte[] dds = File.ReadAllBytes(inputPath); // opens + closes input handle now
+ using var ms = new MemoryStream();
+ var st = TranscodeBytes(dds, ms, forceRgba8, forceResize, forceTranscodeUncompressed, hasS3TC, hasBC7, quality, jobs);
+ if (st != Etc2Status.Transcoded) return st; // nothing written, no output file touched
+
+ using var fs = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ ms.Position = 0;
+ ms.CopyTo(fs);
+ return st;
+ }
+
+ // ---- decode one source level to a tight RGBA8 byte[] --------------------
+ private static readonly BcDecoder _decoder = new BcDecoder();
+
+ private static byte[] LevelToRgba(byte[] dds, in Dds.Info s, int level)
+ {
+ int w = Math.Max(1, s.Width >> level), h = Math.Max(1, s.Height >> level);
+
+ if (!s.Compressed)
+ {
+ long off = s.LevelOffset(level);
+ return Dds.UnpackUncompressed(dds, (int)off, w, h, s);
+ }
+
+ // locate this level's block data
+ long blockOff = s.LevelOffset(level);
+ int blockSize = Dds.BcLevelBytes(w, h, s.BlockBytes);
+ var slice = new byte[blockSize];
+ Buffer.BlockCopy(dds, (int)blockOff, slice, 0, blockSize);
+
+ var format = s.CmpFormat switch
+ {
+ Dds.Bc.Dxt1 => Dds.Dxt1UsesAlpha(dds, blockOff, w, h)
+ ? CompressionFormat.Bc1WithAlpha : CompressionFormat.Bc1,
+ Dds.Bc.Dxt3 => CompressionFormat.Bc2,
+ Dds.Bc.Dxt5 => CompressionFormat.Bc3,
+ Dds.Bc.Bc7 => CompressionFormat.Bc7,
+ _ => CompressionFormat.Bc1,
+ };
+
+ ColorRgba32[] pix = _decoder.DecodeRaw(slice, w, h, format);
+ var rgba = new byte[(long)w * h * 4];
+ for (int i = 0; i < pix.Length; i++)
+ {
+ rgba[i * 4 + 0] = pix[i].r;
+ rgba[i * 4 + 1] = pix[i].g;
+ rgba[i * 4 + 2] = pix[i].b;
+ rgba[i * 4 + 3] = pix[i].a;
+ }
+ return rgba;
+ }
+
+ private static bool EncodeAppend(BinaryWriter bw, byte[] rgba, int w, int h,
+ Etc2Format fmt, float quality, int jobs)
+ {
+ int rc = etc2_encode_rgba8(rgba, w, h, (int)fmt, quality, /*REC709*/1, Math.Max(1, jobs),
+ out IntPtr bits, out int bytes, out _, out _);
+ if (rc != 0 || bits == IntPtr.Zero || bytes <= 0) { if (bits != IntPtr.Zero) etc2_free(bits); return false; }
+ try
+ {
+ var buf = new byte[bytes];
+ Marshal.Copy(bits, buf, 0, bytes);
+ bw.Write((uint)bytes); // KTX imageSize
+ bw.Write(buf); // ETC2 level data (multiple of 8 -> already 4-aligned)
+ }
+ finally { etc2_free(bits); }
+ return true;
+ }
+
+ private static void WriteKtxHeader(BinaryWriter bw, Etc2Format fmt, int w, int h, int mips)
+ {
+ uint glInternal = fmt switch
+ {
+ Etc2Format.Rgb => GL_ETC2_RGB,
+ Etc2Format.Rgba1 => GL_ETC2_RGBA1,
+ _ => GL_ETC2_RGBA8,
+ };
+ uint glBase = fmt == Etc2Format.Rgb ? GL_RGB : GL_RGBA;
+
+ bw.Write(new byte[] { 0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A });
+ bw.Write(0x04030201u); // endianness
+ bw.Write(0u); // glType (compressed)
+ bw.Write(1u); // glTypeSize
+ bw.Write(0u); // glFormat (compressed)
+ bw.Write(glInternal);
+ bw.Write(glBase);
+ bw.Write((uint)w);
+ bw.Write((uint)h);
+ bw.Write(0u); // pixelDepth
+ bw.Write(0u); // arrayElements
+ bw.Write(1u); // faces
+ bw.Write((uint)mips);
+ bw.Write(0u); // bytesOfKeyValueData
+ }
+
+ // 2x2 box downsample of a tight RGBA8 buffer.
+ private static byte[] HalfBox(byte[] src, int sw, int sh, int dw, int dh)
+ {
+ var dst = new byte[(long)dw * dh * 4];
+ for (int y = 0; y < dh; y++)
+ {
+ int y0 = y * 2, y1 = (y * 2 + 1 < sh) ? y * 2 + 1 : y0;
+ for (int x = 0; x < dw; x++)
+ {
+ int x0 = x * 2, x1 = (x * 2 + 1 < sw) ? x * 2 + 1 : x0;
+ int i00 = (y0 * sw + x0) * 4, i01 = (y0 * sw + x1) * 4;
+ int i10 = (y1 * sw + x0) * 4, i11 = (y1 * sw + x1) * 4;
+ int o = (y * dw + x) * 4;
+ for (int c = 0; c < 4; c++)
+ dst[o + c] = (byte)((src[i00 + c] + src[i01 + c] + src[i10 + c] + src[i11 + c] + 2) / 4);
+ }
+ }
+ return dst;
+ }
+
+ private static int MipCount(int w, int h)
+ {
+ int m = 1, d = Math.Max(w, h);
+ while (d > 1) { d >>= 1; m++; }
+ return m;
+ }
+
+ private static byte[] ReadAll(Stream s)
+ {
+ if (s is MemoryStream ms && ms.TryGetBuffer(out var seg) && seg.Offset == 0 && seg.Count == seg.Array!.Length)
+ return seg.Array;
+ using var copy = new MemoryStream();
+ s.CopyTo(copy);
+ return copy.ToArray();
+ }
+ }
+
+ // ---- minimal DDS parsing (header + mip layout), no external deps ------------
+ internal static class Dds
+ {
+ public enum Bc { None, Dxt1, Dxt3, Dxt5, Bc7 }
+
+ public struct Info
+ {
+ public bool Compressed, Handled, HasAlpha;
+ public Bc CmpFormat;
+ public int BlockBytes; // compressed: 8/16
+ public int UnitBytes; // uncompressed: bytes/pixel (2/3/4)
+ public uint RMask, GMask, BMask, AMask; // uncompressed channel bit masks (AMask==0 => no alpha)
+ public int Width, Height, Mips;
+ public int DataOffset;
+
+ public long LevelOffset(int level)
+ {
+ long off = DataOffset;
+ for (int l = 0; l < level; l++)
+ {
+ int lw = Math.Max(1, Width >> l), lh = Math.Max(1, Height >> l);
+ off += Compressed ? BcLevelBytes(lw, lh, BlockBytes) : (long)lw * lh * UnitBytes;
+ }
+ return off;
+ }
+ }
+
+ public static int BcLevelBytes(int w, int h, int blockBytes)
+ => ((Math.Max(1, w) + 3) / 4) * ((Math.Max(1, h) + 3) / 4) * blockBytes;
+
+ private static uint R32(byte[] d, int o) => (uint)(d[o] | (d[o + 1] << 8) | (d[o + 2] << 16) | (d[o + 3] << 24));
+
+ public static bool Parse(byte[] d, out Info s)
+ {
+ s = default; s.Mips = 1;
+ if (d == null || d.Length < 128) return false;
+ if (R32(d, 0) != 0x20534444 || R32(d, 4) != 124) return false;
+
+ s.Height = (int)R32(d, 12); s.Width = (int)R32(d, 16);
+ uint mc = R32(d, 28); s.Mips = mc != 0 ? (int)mc : 1;
+
+ int pf = 76; uint pfFlags = R32(d, pf + 4); uint fourCC = R32(d, pf + 8);
+ uint bits = R32(d, pf + 12);
+ uint rM = R32(d, pf + 16), gM = R32(d, pf + 20), bM = R32(d, pf + 24), aM = R32(d, pf + 28);
+ s.DataOffset = 128;
+
+ const uint DDPF_ALPHA = 0x1, DDPF_FOURCC = 0x4, DDPF_RGB = 0x40, DDPF_LUMINANCE = 0x20000, DDPF_ALPHAONLY = 0x2;
+
+ if ((pfFlags & DDPF_FOURCC) != 0)
+ {
+ if (fourCC == 0x30315844) // "DX10"
+ {
+ if (d.Length < 148) return false;
+ uint dxgi = R32(d, 128); s.DataOffset = 148;
+ switch (dxgi)
+ {
+ case 71: case 72: s.Compressed = true; s.CmpFormat = Bc.Dxt1; s.BlockBytes = 8; s.Handled = true; return true;
+ case 74: case 75: s.Compressed = true; s.CmpFormat = Bc.Dxt3; s.BlockBytes = 16; s.Handled = true; return true;
+ case 77: case 78: s.Compressed = true; s.CmpFormat = Bc.Dxt5; s.BlockBytes = 16; s.Handled = true; return true;
+ case 98: case 99: s.Compressed = true; s.CmpFormat = Bc.Bc7; s.BlockBytes = 16; s.Handled = true; return true;
+ // uncompressed DX10 -> express as masks
+ case 28: case 29: SetUnc(ref s, 4, 0x000000FFu, 0x0000FF00u, 0x00FF0000u, 0xFF000000u); return true; // R8G8B8A8
+ case 87: case 91: SetUnc(ref s, 4, 0x00FF0000u, 0x0000FF00u, 0x000000FFu, 0xFF000000u); return true; // B8G8R8A8
+ case 88: case 93: SetUnc(ref s, 4, 0x00FF0000u, 0x0000FF00u, 0x000000FFu, 0u); return true; // B8G8R8X8
+ default: s.Handled = false; return true;
+ }
+ }
+ switch (fourCC)
+ {
+ case 0x31545844: s.Compressed = true; s.CmpFormat = Bc.Dxt1; s.BlockBytes = 8; s.Handled = true; return true; // DXT1
+ case 0x33545844: s.Compressed = true; s.CmpFormat = Bc.Dxt3; s.BlockBytes = 16; s.Handled = true; return true; // DXT3
+ case 0x35545844: s.Compressed = true; s.CmpFormat = Bc.Dxt5; s.BlockBytes = 16; s.Handled = true; return true; // DXT5
+ default: s.Compressed = true; s.Handled = false; return true; // ATI2/3Dc/FP/etc.
+ }
+ }
+
+ // ---- uncompressed (RGB / RGBA / luminance), 16/24/32-bit, via bit masks ----
+ if ((pfFlags & (DDPF_RGB | DDPF_LUMINANCE | DDPF_ALPHAONLY)) != 0 && (bits == 16 || bits == 24 || bits == 32))
+ {
+ int bpp = (int)bits / 8;
+ bool hasA = (pfFlags & DDPF_ALPHA) != 0 && aM != 0;
+ if ((pfFlags & DDPF_LUMINANCE) != 0)
+ {
+ // L (and optional A): replicate luminance to RGB
+ int lbits = (bits == 16 && hasA) ? 8 : (int)bits;
+ uint lM = rM != 0 ? rM : (uint)((1 << lbits) - 1);
+ SetUnc(ref s, bpp, lM, lM, lM, hasA ? aM : 0u);
+ return true;
+ }
+ SetUnc(ref s, bpp, rM, gM, bM, hasA ? aM : 0u);
+ // if masks were absent, fall back to a sane default for the bit depth
+ if (s.RMask == 0 && s.GMask == 0 && s.BMask == 0)
+ {
+ if (bits == 32) SetUnc(ref s, 4, 0x00FF0000u, 0x0000FF00u, 0x000000FFu, hasA ? 0xFF000000u : 0u);
+ else if (bits == 16) SetUnc(ref s, 2, 0x7C00u, 0x03E0u, 0x001Fu, 0u);
+ else SetUnc(ref s, 3, 0xFF0000u, 0x00FF00u, 0x0000FFu, 0u);
+ }
+ return true;
+ }
+
+ s.Handled = false; return true;
+ }
+
+ private static void SetUnc(ref Info s, int unitBytes, uint r, uint g, uint b, uint a)
+ {
+ s.Compressed = false; s.Handled = true; s.UnitBytes = unitBytes;
+ s.RMask = r; s.GMask = g; s.BMask = b; s.AMask = a; s.HasAlpha = a != 0;
+ }
+
+ private static int Shift(uint m) { int o = 0; if (m == 0) return 0; while ((m & 1) == 0) { m >>= 1; o++; } return o; }
+ private static int Bits(uint m) { int c = 0; while (m != 0) { c += (int)(m & 1); m >>= 1; } return c; }
+ private static byte Chan(uint val, uint mask)
+ {
+ if (mask == 0) return 255;
+ int sh = Shift(mask), bits = Bits(mask);
+ uint v = (val & mask) >> sh;
+ int maxv = (1 << bits) - 1;
+ return (byte)((v * 255 + maxv / 2) / maxv);
+ }
+
+ public static byte[] UnpackUncompressed(byte[] d, int off, int w, int h, in Info s)
+ {
+ var rgba = new byte[(long)w * h * 4];
+ int bpp = s.UnitBytes;
+ for (int i = 0; i < w * h; i++)
+ {
+ int p = off + i * bpp;
+ uint v = bpp switch
+ {
+ 2 => (uint)(d[p] | (d[p + 1] << 8)),
+ 3 => (uint)(d[p] | (d[p + 1] << 8) | (d[p + 2] << 16)),
+ _ => (uint)(d[p] | (d[p + 1] << 8) | (d[p + 2] << 16) | (d[p + 3] << 24)),
+ };
+ rgba[i * 4 + 0] = Chan(v, s.RMask);
+ rgba[i * 4 + 1] = Chan(v, s.GMask);
+ rgba[i * 4 + 2] = Chan(v, s.BMask);
+ rgba[i * 4 + 3] = s.AMask != 0 ? Chan(v, s.AMask) : (byte)255;
+ }
+ return rgba;
+ }
+
+ // BC1 block scan: punch-through alpha used when color0 <= color1 and an index == 3.
+ public static bool Dxt1UsesAlpha(byte[] d, long blockOff, int w, int h)
+ {
+ int bw = (w + 3) / 4, bh = (h + 3) / 4;
+ for (int i = 0; i < bw * bh; i++)
+ {
+ int b = (int)blockOff + i * 8;
+ int c0 = d[b] | (d[b + 1] << 8);
+ int c1 = d[b + 2] | (d[b + 3] << 8);
+ if (c0 <= c1)
+ {
+ uint idx = (uint)(d[b + 4] | (d[b + 5] << 8) | (d[b + 6] << 16) | (d[b + 7] << 24));
+ for (int t = 0; t < 16; t++) if (((idx >> (t * 2)) & 3) == 3) return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/Knossos.NET/Classes/Inno/InnoArchive.cs b/Knossos.NET/Classes/Inno/InnoArchive.cs
new file mode 100644
index 00000000..6460fd7e
--- /dev/null
+++ b/Knossos.NET/Classes/Inno/InnoArchive.cs
@@ -0,0 +1,1125 @@
+// 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));
+ return new WindowStream(_stream, start, span, _io);
+ }
+ 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/Classes/KnUtils.cs b/Knossos.NET/Classes/KnUtils.cs
index 5efdde2d..294ace1c 100644
--- a/Knossos.NET/Classes/KnUtils.cs
+++ b/Knossos.NET/Classes/KnUtils.cs
@@ -1464,7 +1464,8 @@ public static void OpenFileInOS(string fullpath, string mimetype = "text/plain")
Log.Add(Log.LogSeverity.Warning, "KnUtils.OpenFileInOS", "File does not exist: " + fullpath);
}
}
-
+
+ ///
/// Determines if the current OS meets the requirements for .NET 10 / "latest" channel updates.
/// Windows 10+ or macOS 12.0+ qualify.
///
@@ -1486,5 +1487,17 @@ public static bool IsModernOS()
// Linux and anything else: treat as modern (no restriction planned)
return true;
}
+
+ ///
+ /// Gets the recommended and max number of threads to use on the host computer
+ ///
+ public static (int recommended, int max) GetMaxThreads()
+ {
+ int max = Environment.ProcessorCount - 1; // leave a core for the UI/OS
+ if (max < 1) max = 1;
+ int r = max / 2;
+ if (r < 1) r = 1;
+ return (r, max);
+ }
}
}
diff --git a/Knossos.NET/Knossos.NET.csproj b/Knossos.NET/Knossos.NET.csproj
index 31e9d4ea..f0f80ee5 100644
--- a/Knossos.NET/Knossos.NET.csproj
+++ b/Knossos.NET/Knossos.NET.csproj
@@ -65,6 +65,7 @@
+
diff --git a/Knossos.NET/Models/GlobalSettings.cs b/Knossos.NET/Models/GlobalSettings.cs
index e87de9af..067fa740 100644
--- a/Knossos.NET/Models/GlobalSettings.cs
+++ b/Knossos.NET/Models/GlobalSettings.cs
@@ -61,6 +61,61 @@ public class GlobalSettings
"nomusic"
};
+ public struct Etc2Config
+ {
+ public bool TranscodeMods { get; set; }
+ public int Jobs { get; set; }
+ public int Quality { get; set; }
+ public bool Resize { get; set; }
+ public bool ForceBC7 { get; set; }
+ public bool ForceS3TC { get; set; }
+
+ public Etc2Config()
+ {
+ TranscodeMods = false;
+ Jobs = 2;
+ Quality = 10;
+ Resize = true;
+ ForceBC7 = false;
+ ForceS3TC = false;
+ }
+
+ public Etc2Config(bool transcodeMods, int jobs, int quality, bool resize, bool forceS3tc, bool forceBC7)
+ {
+ TranscodeMods = transcodeMods;
+ Jobs = jobs;
+ Quality = quality;
+ Resize = resize;
+ ForceBC7 = forceBC7;
+ ForceS3TC = forceS3tc;
+ }
+
+ public static bool operator ==(Etc2Config left, Etc2Config right)
+ {
+ return left.TranscodeMods == right.TranscodeMods && left.Jobs == right.Jobs && left.Quality == right.Quality && left.Resize == right.Resize && left.ForceS3TC == right.ForceS3TC && left.ForceBC7 == right.ForceBC7;
+ }
+
+ public static bool operator !=(Etc2Config left, Etc2Config right)
+ {
+ return !(left == right);
+ }
+
+ public bool Equals(Etc2Config other)
+ {
+ return this == other;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is Etc2Config other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Jobs, Quality, TranscodeMods, Resize, ForceBC7, ForceS3TC);
+ }
+ }
+
struct Resolution
{
public uint width { get; set; }
@@ -181,6 +236,22 @@ public bool closeToTray {
[JsonPropertyName("mod_filecopy_extension_skip")]
public List? skipExtensionsModFilecopy { get; set; } = new List() { ".svn", ".github", ".git", ".gitattributes", ".gitignore", ".md" };
+ [JsonIgnore]
+ private Etc2Config? _modEtc2TranscodeConfig { get; set; } = null;
+ [JsonPropertyName("mod_etc2_transcode_config")]
+ public Etc2Config? modEtc2TranscodeConfig
+ {
+ get { return _modEtc2TranscodeConfig; }
+ set
+ {
+ if (_modEtc2TranscodeConfig != value)
+ {
+ _modEtc2TranscodeConfig = value;
+ pendingChangesOnAppClose = true;
+ }
+ }
+ }
+
/*
* Settings that can wait to be saved at app close so we dont have to call save() all the time
* use JsonIgnore, private and '_' for the actual variable name
@@ -682,6 +753,7 @@ public void Load()
antiStuck = tempSettings.antiStuck;
maxUploadRetries = tempSettings.maxUploadRetries;
singleViewMode = tempSettings.singleViewMode;
+ modEtc2TranscodeConfig = tempSettings.modEtc2TranscodeConfig;
if (hiddenModIds.Any())
{
foreach (var hiddenMod in hiddenModIds)
@@ -697,6 +769,14 @@ public void Load()
SetCustomModeValues();
+ if (KnUtils.IsAndroid && modEtc2TranscodeConfig == null) // load default
+ {
+ var threads = KnUtils.GetMaxThreads();
+ var gpuSupport = AndroidHelper.GpuSupportsBCnTexturesOpenGL();
+ var enabled = KnUtils.IsAndroid ? !gpuSupport.s3tc || !gpuSupport.bc7 : false;
+ modEtc2TranscodeConfig = new Etc2Config(enabled, threads.recommended, 10, true, false, false);
+ }
+
pendingChangesOnAppClose = false;
}
diff --git a/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs b/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs
index 315d28be..77b5ae9c 100644
--- a/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs
+++ b/Knossos.NET/ViewModels/GlobalSettingsViewModel.cs
@@ -84,6 +84,56 @@ internal int AndroidFolderPathsSelectedIndex
internal bool quickStartButtonVisibility = true;
[ObservableProperty]
internal bool expandKnossosSettings = false;
+ /*ETC2 Transcode*/
+ [ObservableProperty]
+ internal int maxTranscodeThreads = 1;
+ [ObservableProperty]
+ internal bool bc7Support = false;
+ [ObservableProperty]
+ internal bool s3tcSupport = false;
+
+ /* Asset Transcoding */
+ private bool _etc2transcodeEnabled = false;
+ internal bool Etc2transcodeEnabled
+ {
+ get { return _etc2transcodeEnabled; }
+ set { if (_etc2transcodeEnabled != value) { this.SetProperty(ref _etc2transcodeEnabled, value); Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(value, Etc2Jobs, Etc2Quality, Etc2ResizeEnabled, ForceS3TC, ForceBC7); UnCommitedChanges = true; } }
+ }
+
+ private bool _etc2ResizeEnabled = true;
+ internal bool Etc2ResizeEnabled
+ {
+ get { return _etc2ResizeEnabled; }
+ set { if (_etc2ResizeEnabled != value) { this.SetProperty(ref _etc2ResizeEnabled, value); Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(Etc2transcodeEnabled, Etc2Jobs, Etc2Quality, value, ForceS3TC, ForceBC7); UnCommitedChanges = true; } }
+ }
+
+ private int _etc2Quality = 10;
+ internal int Etc2Quality
+ {
+ get { return _etc2Quality; }
+ set { if (_etc2Quality != value) { this.SetProperty(ref _etc2Quality, value); Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(Etc2transcodeEnabled, Etc2Jobs, value, Etc2ResizeEnabled, ForceS3TC, ForceBC7); UnCommitedChanges = true; } }
+ }
+
+ private int _etc2Jobs = 1;
+ internal int Etc2Jobs
+ {
+ get { return _etc2Jobs; }
+ set { if (_etc2Jobs != value) { this.SetProperty(ref _etc2Jobs, value); Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(Etc2transcodeEnabled, value, Etc2Quality, Etc2ResizeEnabled, ForceS3TC, ForceBC7); UnCommitedChanges = true; } }
+ }
+
+ private bool _forceS3TC = false;
+ internal bool ForceS3TC
+ {
+ get { return _forceS3TC; }
+ set { if (_forceS3TC != value) { this.SetProperty(ref _forceS3TC, value); Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(Etc2transcodeEnabled, Etc2Jobs, Etc2Quality, Etc2ResizeEnabled, value, ForceBC7); UnCommitedChanges = true; } }
+ }
+ private bool _forceBC7 = false;
+ internal bool ForceBC7
+ {
+ get { return _forceBC7; }
+ set { if (_forceBC7 != value) { this.SetProperty(ref _forceBC7, value); Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(Etc2transcodeEnabled, Etc2Jobs, Etc2Quality, Etc2ResizeEnabled, ForceS3TC, value); UnCommitedChanges = true; } }
+ }
+ /* */
/* Knossos Settings */
[ObservableProperty]
@@ -771,6 +821,25 @@ public async Task LoadDataAsync()
MaxUploadsRetries = Knossos.globalSettings.maxUploadRetries;
SingleView = Knossos.globalSettings.singleViewMode;
+ if (KnUtils.IsAndroid)
+ {
+ var threads = KnUtils.GetMaxThreads();
+ MaxTranscodeThreads = threads.max;
+ var gpu = AndroidHelper.GpuSupportsBCnTexturesOpenGL();
+ S3tcSupport = gpu.s3tc;
+ Bc7Support = gpu.bc7;
+ // default config is always created in Globasettings.Load() on android if it does not exist
+ if (Knossos.globalSettings.modEtc2TranscodeConfig.HasValue)
+ {
+ Etc2Jobs = Knossos.globalSettings.modEtc2TranscodeConfig.Value.Jobs;
+ Etc2Quality = Knossos.globalSettings.modEtc2TranscodeConfig.Value.Quality;
+ Etc2transcodeEnabled = Knossos.globalSettings.modEtc2TranscodeConfig.Value.TranscodeMods;
+ Etc2ResizeEnabled = Knossos.globalSettings.modEtc2TranscodeConfig.Value.Resize;
+ ForceBC7 = Knossos.globalSettings.modEtc2TranscodeConfig.Value.ForceBC7;
+ ForceS3TC = Knossos.globalSettings.modEtc2TranscodeConfig.Value.ForceS3TC;
+ }
+ }
+
/* VIDEO SETTINGS */
//RESOLUTION
ResolutionItems.Clear();
@@ -1356,6 +1425,11 @@ internal void SaveCommand()
Knossos.globalSettings.maxUploadRetries = MaxUploadsRetries;
Knossos.globalSettings.singleViewMode = SingleView;
+ if (IsAndroid)
+ {
+ Knossos.globalSettings.modEtc2TranscodeConfig = new GlobalSettings.Etc2Config(Etc2transcodeEnabled, Etc2Jobs, Etc2Quality, Etc2ResizeEnabled, ForceS3TC, ForceBC7);
+ }
+
/* VIDEO */
//Resolution
if (ResolutionSelectedIndex + 1 <= ResolutionItems.Count)
diff --git a/Knossos.NET/ViewModels/TaskViewModel.cs b/Knossos.NET/ViewModels/TaskViewModel.cs
index 9734736e..d16c40fb 100644
--- a/Knossos.NET/ViewModels/TaskViewModel.cs
+++ b/Knossos.NET/ViewModels/TaskViewModel.cs
@@ -379,6 +379,44 @@ public async Task CompressMod(Mod mod)
}
}
+ ///
+ /// Transcode all DDS BCn files in a mod to KTX ETC2
+ ///
+ ///
+ ///
+ public async Task TranscodeMod(Mod mod)
+ {
+ if (mod.type == ModType.engine)
+ {
+ //If this is an engine build then do nothing
+ }
+ else
+ {
+ using (var cancelSource = new CancellationTokenSource())
+ {
+ var newTask = new TaskItemViewModel();
+ Dispatcher.UIThread.Invoke(() =>
+ {
+ TaskList.Add(newTask);
+ taskQueue.Enqueue(newTask);
+ });
+ var res = await newTask.TranscodeMod(mod, cancelSource).ConfigureAwait(false);
+ if (res && Knossos.inSingleTCMode)
+ {
+ try
+ {
+ TaskList.Remove(newTask);
+ }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "TaskViewModel.TranscodeMod()", ex);
+ }
+ Dispatcher.UIThread.Invoke(() => TaskViewModel.Instance?.AddMessageTask("Completed: " + newTask.Name), DispatcherPriority.Background);
+ }
+ }
+ }
+ }
+
///
/// Decompresses a mod from LZ41 into regular files
/// Reemplaces .vpc for .vp and decompress .lz41 files inside the data folder
diff --git a/Knossos.NET/ViewModels/Templates/TaskItemViewModel.cs b/Knossos.NET/ViewModels/Templates/TaskItemViewModel.cs
index f7546ad1..3ea606fc 100644
--- a/Knossos.NET/ViewModels/Templates/TaskItemViewModel.cs
+++ b/Knossos.NET/ViewModels/Templates/TaskItemViewModel.cs
@@ -168,11 +168,11 @@ public partial class TaskItemViewModel : ViewModelBase
[ObservableProperty]
internal string info = string.Empty;
[ObservableProperty]
- internal float progressBarMin = 0;
+ internal int progressBarMin = 0;
[ObservableProperty]
- internal float progressBarMax = 0;
+ internal int progressBarMax = 0;
[ObservableProperty]
- internal float progressCurrent = 0;
+ internal int progressCurrent = 0;
[ObservableProperty]
internal string name = string.Empty;
[ObservableProperty]
diff --git a/Knossos.NET/ViewModels/Templates/Tasks/DownloadFile.cs b/Knossos.NET/ViewModels/Templates/Tasks/DownloadFile.cs
index d7c9bcf4..a1b81420 100644
--- a/Knossos.NET/ViewModels/Templates/Tasks/DownloadFile.cs
+++ b/Knossos.NET/ViewModels/Templates/Tasks/DownloadFile.cs
@@ -46,7 +46,7 @@ public partial class TaskItemViewModel : ViewModelBase
{
if (progressPercentage.HasValue && filesize.HasValue)
{
- ProgressCurrent = (float)progressPercentage.Value;
+ ProgressCurrent = (int)progressPercentage.Value;
Info = KnUtils.FormatBytes(bytesDownloaded) + " / " + KnUtils.FormatBytes(filesize.Value) + " @ " + speed;
}
diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs
index 428c274e..201c21da 100644
--- a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs
+++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs
@@ -1,17 +1,19 @@
-using Avalonia.Threading;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Knossos.NET.Classes;
using Knossos.NET.Models;
using Knossos.NET.Views;
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
-using System.Threading.Tasks;
+using System.Text.RegularExpressions;
using System.Threading;
-using Knossos.NET.Classes;
+using System.Threading.Tasks;
using VP.NET;
-using Avalonia.Media;
-using System.Text.RegularExpressions;
+using static Etc2.Dds;
namespace Knossos.NET.ViewModels
{
@@ -21,7 +23,7 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource
{
string? modPath = null;
Mod? installed = null;
-
+ bool transcodingNeeded = false;
try
{
if (!TaskIsSet)
@@ -57,6 +59,10 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource
}
}
+ //Determine if we need to transcode
+ var etc2Config = Knossos.globalSettings.modEtc2TranscodeConfig ?? new GlobalSettings.Etc2Config();
+ transcodingNeeded = etc2Config.TranscodeMods;
+
if (!mod.devMode) //Do not compress dev mode mods
{
compressMod = manualCompress;
@@ -298,6 +304,11 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource
ProgressCurrent = 0;
ProgressBarMax = installed == null ? (files.Count * 2) + 1 : (files.Count * 2);
ProgressBarMax += vPExtractionNeeded;
+
+ if (transcodingNeeded)
+ {
+ ProgressBarMax += 1;
+ }
if (compressMod)
{
ProgressBarMax += 1;
@@ -590,6 +601,24 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource
mod.modSettings.SetInitialFilePath(mod.fullPath);
+ //We have to transcode?
+ if (transcodingNeeded)
+ {
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ var cpTask = new TaskItemViewModel();
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TaskList.Insert(0, cpTask);
+ });
+ await cpTask.TranscodeMod(mod, cancellationTokenSource, true);
+ ProgressCurrent++;
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+ }
+
//We have to compress?
if (compressMod)
{
diff --git a/Knossos.NET/ViewModels/Templates/Tasks/PatchEffFiles.cs b/Knossos.NET/ViewModels/Templates/Tasks/PatchEffFiles.cs
new file mode 100644
index 00000000..a0815e31
--- /dev/null
+++ b/Knossos.NET/ViewModels/Templates/Tasks/PatchEffFiles.cs
@@ -0,0 +1,215 @@
+using Avalonia.Threading;
+using Knossos.NET.Classes;
+using Knossos.NET.Models;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using VP.NET;
+
+namespace Knossos.NET.ViewModels
+{
+ public partial class TaskItemViewModel : ViewModelBase
+ {
+ ///
+ /// Third transcode stage. Runs AFTER the loose + vp .dds transcoding.
+ ///
+ /// Walks every .eff in the mod (loose files AND inside .vp/.vpc archives). For each $Type: dds eff it
+ /// builds the theoretical frame list and checks how many frames are now available as .ktx (either just
+ /// transcoded this run, present in , or already .ktx in the layout):
+ /// - 0 frames converted -> leave the eff alone (all-uncompressed UI animation, or nothing present).
+ /// - >=1 frame converted -> the eff is going ktx: patch its $Type to ktx, and add every frame that is
+ /// still a .dds (no .ktx yet) to the returned "force" list.
+ /// Loose effs are patched in place; effs inside an archive are patched and the archive is saved back to
+ /// the same path. The returned set is the .dds frame names that a 2nd, forced pass must still convert.
+ ///
+ private async Task> PatchEffFiles(Mod mod, HashSet transcodedNames, CancellationTokenSource? cancelSource = null)
+ {
+ var workFolder = "";
+ var openArchives = new List<(string path, VPContainer vp)>();
+ var forceList = new HashSet(StringComparer.OrdinalIgnoreCase);
+ try
+ {
+ if (TaskIsSet) throw new Exception("The task is already set, it cant be changed or re-assigned.");
+ TaskIsSet = true;
+ ProgressBarMax = 1;
+ ProgressCurrent = 0;
+ ShowProgressText = false;
+ cancellationTokenSource = cancelSource ?? new CancellationTokenSource();
+ CancelButtonVisible = false;
+ Name = "Patching eff files";
+ var token = cancellationTokenSource.Token;
+ if (token.IsCancellationRequested) throw new TaskCanceledException();
+
+ workFolder = Path.Combine(KnUtils.GetFSODataFolderPath(), "effPatchTemp");
+ Directory.CreateDirectory(workFolder);
+
+ var dataDir = mod.devMode ? mod.fullPath : Path.Combine(mod.fullPath, "data");
+
+ // 1) Gather every archive in the mod (.vp AND .vpc; they are interchangeable - the library is
+ // transparent to compression). Look both at the mod root and inside the data folder.
+ var archivePaths = new List();
+ foreach (var dir in new[] { mod.fullPath, dataDir }.Distinct())
+ {
+ if (!Directory.Exists(dir)) continue;
+ archivePaths.AddRange(Directory.GetFiles(dir, "*.vp", SearchOption.AllDirectories));
+ archivePaths.AddRange(Directory.GetFiles(dir, "*.vpc", SearchOption.AllDirectories));
+ }
+ archivePaths = archivePaths.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ // 2) Build a layout-wide set of existing file names (loose + inside archives), plus the list of
+ // effs to consider. Only names are needed (no decompression of frame pixels).
+ var existing = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var looseEffPaths = new List();
+
+ if (Directory.Exists(dataDir))
+ {
+ foreach (var f in Directory.GetFiles(dataDir, "*.*", SearchOption.AllDirectories))
+ {
+ existing.Add(Path.GetFileName(f).ToLowerInvariant());
+ if (Path.GetExtension(f).Equals(".eff", StringComparison.OrdinalIgnoreCase))
+ looseEffPaths.Add(f);
+ }
+ }
+
+ var archiveEffNodes = new List<(string archivePath, VPContainer vp, VPFile node)>();
+ foreach (var apath in archivePaths)
+ {
+ token.ThrowIfCancellationRequested();
+ VPContainer vp;
+ try { vp = new VPContainer(); await vp.LoadVP(apath); }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.PatchEffFiles()", $"Could not open '{Path.GetFileName(apath)}': {ex.Message}");
+ continue;
+ }
+ openArchives.Add((apath, vp));
+ if (vp.vpFiles == null) continue;
+ foreach (var root in vp.vpFiles)
+ {
+ foreach (var n in root.SearchForFileExtension(".dds")) existing.Add(n.info.name.ToLowerInvariant());
+ foreach (var n in root.SearchForFileExtension(".ktx")) existing.Add(n.info.name.ToLowerInvariant());
+ foreach (var n in root.SearchForFileExtension(".eff")) archiveEffNodes.Add((apath, vp, n));
+ }
+ }
+
+ bool FrameKtxExists(string baseLower, int i) => existing.Contains($"{baseLower}_{i:D4}.ktx");
+ bool FrameDdsExists(string baseLower, int i) => existing.Contains($"{baseLower}_{i:D4}.dds");
+ bool FrameTranscodedThisRun(string baseLower, int i) => transcodedNames.Contains($"{baseLower}_{i:D4}.dds");
+
+ // Decide a single eff. patchAction performs the actual $Type rewrite and returns true if it changed.
+ async Task ConsiderEffAsync(EffHelper eff, Func> patchAction, string displayName)
+ {
+ if (!eff.IsDds) return; // ktx already done; png/tga/pcx/jpg/ani never apply
+
+ var baseLower = eff.BaseName.ToLowerInvariant();
+ int converted = 0;
+ var missing = new List();
+ for (int i = 0; i < eff.FrameCount; i++)
+ {
+ if (FrameKtxExists(baseLower, i) || FrameTranscodedThisRun(baseLower, i)) converted++;
+ else if (FrameDdsExists(baseLower, i)) missing.Add($"{baseLower}_{i:D4}.dds");
+ // neither ktx nor dds present -> frame genuinely absent (eff was already incomplete); ignored
+ }
+
+ if (converted == 0)
+ {
+ // No frame became ktx -> this is an all-uncompressed UI animation (or has no usable frames).
+ // Leave it exactly as-is.
+ return;
+ }
+
+ var changed = await patchAction().ConfigureAwait(false);
+ if (changed)
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.PatchEffFiles()", $"Patched eff '{displayName}' -> ktx");
+ foreach (var m in missing) forceList.Add(m);
+ }
+
+ // 3a) Loose effs -> patch in place.
+ foreach (var effPath in looseEffPaths)
+ {
+ token.ThrowIfCancellationRequested();
+ string text;
+ EffHelper eff;
+ Dispatcher.UIThread.Invoke(() => { Info = $"{Path.GetFileName(effPath)}"; });
+ try { text = await File.ReadAllTextAsync(effPath, token).ConfigureAwait(false); eff = EffHelper.Parse(text, Path.GetFileName(effPath)); }
+ catch (Exception ex) { Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.PatchEffFiles()", $"Skipping unreadable eff '{Path.GetFileName(effPath)}': {ex.Message}"); continue; }
+
+ await ConsiderEffAsync(eff, () => EffHelper.PatchLooseAsync(effPath, "ktx", token), Path.GetFileName(effPath));
+ Dispatcher.UIThread.Invoke(() => { Info = ""; });
+ }
+
+ // 3b) Archive effs -> patch the node and mark the archive dirty.
+ var dirtyArchives = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var (apath, vp, node) in archiveEffNodes)
+ {
+ token.ThrowIfCancellationRequested();
+ EffHelper eff;
+ try
+ {
+ using var ms = new MemoryStream();
+ await node.ReadToStream(ms).ConfigureAwait(false);
+ eff = await EffHelper.ParseAsync(ms, node.info.name).ConfigureAwait(false);
+ }
+ catch (Exception ex) { Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.PatchEffFiles()", $"Skipping unreadable eff '{node.info.name}': {ex.Message}"); continue; }
+
+ await ConsiderEffAsync(eff, async () =>
+ {
+ var changed = await EffHelper.PatchInVpAsync(node, "ktx", workFolder, token).ConfigureAwait(false);
+ if (changed) dirtyArchives.Add(apath);
+ return changed;
+ }, node.info.name);
+ }
+
+ // 4) Save the archives whose eff we patched, back to the same path (temp + replace).
+ foreach (var (apath, vp) in openArchives)
+ {
+ token.ThrowIfCancellationRequested();
+ if (!dirtyArchives.Contains(apath)) continue;
+ await SaveArchiveInPlace(vp, apath, cancellationTokenSource).ConfigureAwait(false);
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.PatchEffFiles()", $"Saved patched archive '{Path.GetFileName(apath)}'");
+ }
+
+ IsCompleted = true;
+ ProgressCurrent = ProgressBarMax;
+ Info = "Eff patched. Frames to force: " + forceList.Count;
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.PatchEffFiles()", "Eff patching finished. " + Info);
+ if (workFolder != "" && Directory.Exists(workFolder)) Directory.Delete(workFolder, true);
+ return forceList;
+ }
+ catch (TaskCanceledException)
+ {
+ IsCompleted = false; IsCancelled = true; CancelButtonVisible = false; Info = "Task Cancelled";
+ if (cancelSource == null) cancellationTokenSource?.Dispose();
+ if (workFolder != "" && Directory.Exists(workFolder)) Directory.Delete(workFolder, true);
+ return forceList;
+ }
+ catch (Exception ex)
+ {
+ IsCompleted = false; IsCancelled = true; CancelButtonVisible = false; Info = "Task Failed";
+ if (cancelSource == null) cancellationTokenSource?.Dispose();
+ if (workFolder != "" && Directory.Exists(workFolder)) Directory.Delete(workFolder, true);
+ Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.PatchEffFiles()", ex);
+ return forceList;
+ }
+ }
+
+ ///
+ /// Save a vp/vpc back to its own path. We cannot write to the file we are reading, so we save to a temp
+ /// sibling and then atomically replace the original (keeping its extension, .vp or .vpc, untouched - the
+ /// library compresses or not based on the data already in the container).
+ ///
+ private async Task SaveArchiveInPlace(VPContainer vp, string archivePath, CancellationTokenSource cts)
+ {
+ var tmp = archivePath + ".tmp";
+ if (File.Exists(tmp)) File.Delete(tmp);
+ Dispatcher.UIThread.Invoke(() => { Name = $"Patching eff files - {Path.GetFileName(archivePath)}"; });
+ await vp.SaveAsAsync(tmp, compressionCallback, cts);
+ Dispatcher.UIThread.Invoke(() => { Name = "Patching eff files"; });
+ File.Delete(archivePath);
+ File.Move(tmp, archivePath);
+ }
+ }
+}
diff --git a/Knossos.NET/ViewModels/Templates/Tasks/TranscodeLosseFiles.cs b/Knossos.NET/ViewModels/Templates/Tasks/TranscodeLosseFiles.cs
new file mode 100644
index 00000000..c421ad29
--- /dev/null
+++ b/Knossos.NET/ViewModels/Templates/Tasks/TranscodeLosseFiles.cs
@@ -0,0 +1,168 @@
+using Avalonia.Markup.Xaml.Templates;
+using Avalonia.Threading;
+using Etc2;
+using Knossos.NET.Classes;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime;
+using System.Threading;
+using System.Threading.Tasks;
+using VP.NET;
+using static Etc2.Dds;
+
+namespace Knossos.NET.ViewModels
+{
+ public partial class TaskItemViewModel : ViewModelBase
+ {
+ private int _progressCounter = 0;
+ ///
+ /// Transcode .dds BCn files to .ktx ETC2
+ ///
+ ///
+ ///
+ ///
+ ///
+ private async Task TranscodeLosseFiles(List filePaths, int alreadySkipped, CancellationTokenSource? cancelSource = null, ConcurrentBag? transcodedNames = null, HashSet? forceList = null)
+ {
+ try
+ {
+ if (!TaskIsSet)
+ {
+ TaskIsSet = true;
+ ProgressBarMax = filePaths.Count();
+ ProgressCurrent = 0;
+ ShowProgressText = false;
+ if (cancelSource != null)
+ {
+ cancellationTokenSource = cancelSource;
+ }
+ else
+ {
+ cancellationTokenSource = new CancellationTokenSource();
+ }
+ CancelButtonVisible = false;
+ Name = "Transcoding loose files";
+
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ int skippedCount = alreadySkipped;
+ int compressedCount = 0;
+
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.TranscodeLosseFiles()", "Starting to transcode loose files");
+ var config = Knossos.globalSettings.modEtc2TranscodeConfig ?? new Models.GlobalSettings.Etc2Config();
+ var hardwareSupport = AndroidHelper.GpuSupportsBCnTexturesOpenGL();
+ if (config.ForceBC7) hardwareSupport.bc7 = false;
+ if (config.ForceS3TC) hardwareSupport.s3tc = false;
+
+ await Parallel.ForEachAsync(filePaths, new ParallelOptions { MaxDegreeOfParallelism = 2, CancellationToken = cancellationTokenSource.Token },
+ async (file, token) =>
+ {
+ if (Path.GetExtension(file).ToLowerInvariant() != ".dds") { Interlocked.Increment(ref skippedCount); return; }
+
+ var nameLower = Path.GetFileName(file).ToLowerInvariant();
+ if (forceList != null && !forceList.Contains(nameLower)) { Interlocked.Increment(ref skippedCount); return; }
+
+ var filename = Path.GetFileNameWithoutExtension(file).ToLowerInvariant();
+ var outputFileName = Path.Combine(Path.GetDirectoryName(file) ?? "", filename + ".ktx");
+ bool forceRGBA8 = filename.ToLower().Contains("normal") || filename.ToLower().Contains("reflect"); //normal and reflect textures must use ETC2 rgba8
+ if (hardwareSupport.s3tc && hardwareSupport.bc7) forceRGBA8 = false;
+ if (forceList != null) forceRGBA8 = true;
+
+ int done = Interlocked.Increment(ref _progressCounter);
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ProgressCurrent = done;
+ Info = $"{done} / {ProgressBarMax} {filename}";
+ });
+
+ Etc2Status result;
+ try
+ {
+ result = await Task.Run(() => Etc2Transcoder.TranscodeFile(file, outputFileName,
+ forceRgba8: forceRGBA8, forceResize: config.Resize, quality: config.Quality, jobs: config.Jobs,
+ forceTranscodeUncompressed: forceList != null, hasS3TC : hardwareSupport.s3tc, hasBC7: hardwareSupport.bc7), token);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ Interlocked.Increment(ref skippedCount);
+ Log.Add(Log.LogSeverity.Error, "TranscodeLosseFiles()", $"Error transcoding {filename}: {ex.Message}");
+ return;
+ }
+
+ if (result == Etc2Status.Transcoded)
+ {
+ File.Delete(file);
+ Interlocked.Increment(ref compressedCount);
+ lock (transcodedNames!) transcodedNames.Add(nameLower);
+ }
+ else
+ {
+ Interlocked.Increment(ref skippedCount);
+ if (result == Etc2Status.NotCompressed)
+ Log.Add(Log.LogSeverity.Information, "TranscodeLosseFiles()", $"Skipping {filename} (DDS uncompressed).");
+ else if (result == Etc2Status.Skipped) { /* silent skip */ }
+ else
+ Log.Add(Log.LogSeverity.Error, "TranscodeLosseFiles()", $"Error transcoding {filename}: {result}");
+ }
+
+ token.ThrowIfCancellationRequested();
+ });
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
+ GC.Collect(2, GCCollectionMode.Forced, blocking: true);
+
+ IsCompleted = true;
+ ProgressCurrent = ProgressBarMax;
+ Info = "Compressed: " + compressedCount + " Skipped: " + skippedCount;
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.TranscodeLosseFiles()", "Transcoding Loose files finished: " + Info);
+ return true;
+ }
+ else
+ {
+ throw new Exception("The task is already set, it cant be changed or re-assigned.");
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ /*
+ Task cancel requested by user
+ */
+ IsCompleted = false;
+ IsCancelled = true;
+ CancelButtonVisible = false;
+ Info = "Task Cancelled";
+ //Only dispose the token if it was created locally
+ if (cancelSource == null)
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ return false;
+ }
+ catch (Exception ex)
+ {
+ IsCompleted = false;
+ CancelButtonVisible = false;
+ IsCancelled = true;
+ Info = "Task Failed";
+ //Only dispose the token if it was created locally
+ if (cancelSource == null)
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.TranscodeLosseFiles()", ex);
+ return false;
+ }
+ }
+ }
+}
diff --git a/Knossos.NET/ViewModels/Templates/Tasks/TranscodeMod.cs b/Knossos.NET/ViewModels/Templates/Tasks/TranscodeMod.cs
new file mode 100644
index 00000000..233e6358
--- /dev/null
+++ b/Knossos.NET/ViewModels/Templates/Tasks/TranscodeMod.cs
@@ -0,0 +1,320 @@
+using Avalonia.Threading;
+using Knossos.NET.Models;
+using System;
+using System.Collections.Concurrent; // EFF
+using System.Collections.Generic; // EFF
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Threading;
+using VP.NET;
+using Etc2;
+
+namespace Knossos.NET.ViewModels
+{
+ public partial class TaskItemViewModel : ViewModelBase
+ {
+ public async Task TranscodeMod(Mod mod, CancellationTokenSource? cancelSource = null, bool isSubTask = false)
+ {
+ try
+ {
+ if (!TaskIsSet)
+ {
+ TaskIsSet = true;
+ if (!isSubTask)
+ {
+ CancelButtonVisible = true;
+ Name = "Transcoding mod: " + mod.title + " " + mod.version;
+ }
+ else
+ {
+ Name = "Transcoding mod";
+ }
+
+ ShowProgressText = false;
+ await Dispatcher.UIThread.InvokeAsync(() => {
+ TaskRoot.Add(this);
+ });
+ ProgressBarMin = 0;
+ ProgressCurrent = 0;
+ Info = "In Queue";
+
+ if (cancelSource != null)
+ {
+ cancellationTokenSource = cancelSource;
+ }
+ else
+ {
+ cancellationTokenSource = new CancellationTokenSource();
+ }
+
+ //Wait in Queue
+ if (!isSubTask)
+ {
+ while (TaskViewModel.Instance!.taskQueue.Count > 0 && TaskViewModel.Instance!.taskQueue.Peek() != this)
+ {
+ await Task.Delay(1000);
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+ }
+ }
+
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.TranscodeMod()", "Starting to transcode Mod: " + mod.title);
+
+ var etc2Version = Etc2Transcoder.NativeVersion();
+
+ if (etc2Version != "")
+ {
+ Log.Add(Log.LogSeverity.Information, "TaskViewModel.TranscodeMod()", $"Lib loaded: {etc2Version}");
+ }
+ else
+ {
+ throw new TaskCanceledException("Unable to load etc2native library!");
+ }
+
+ //get all .vp / .vpc
+ var vpFiles = Directory.GetFiles(mod.fullPath, "*.vp").Concat(Directory.GetFiles(mod.fullPath, "*.vpc")).ToList();
+ ProgressBarMax = vpFiles.Count() + 2;
+
+ // list of transcoded dds files
+ var transcodedNames = new ConcurrentBag();
+
+ //Loose Files Compression
+ if (Directory.Exists(mod.fullPath + Path.DirectorySeparatorChar + "data") || mod.devMode)
+ {
+ var searchDir = mod.devMode ? mod.fullPath : mod.fullPath + Path.DirectorySeparatorChar + "data";
+ var allFilesInDataFolder = Directory.GetFiles(searchDir, "*.*", SearchOption.AllDirectories).ToList();
+ int skipped = 0;
+ //Filter
+ foreach (var fileInData in allFilesInDataFolder.ToList())
+ {
+ var file = new FileInfo(fileInData);
+
+ if (file.IsReadOnly || file.Extension.ToLower() == ".ktx" || file.Extension.ToLower() == ".vp" || file.Extension.ToLower() == ".vpc")
+ {
+ if (file.Extension.ToLower() == ".vp" || file.Extension.ToLower() == ".vpc")
+ {
+ vpFiles.Add(fileInData);
+ ProgressBarMax++;
+ }
+ allFilesInDataFolder.Remove(fileInData);
+ skipped++;
+ }
+ }
+ //Process
+ await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var fileTask = new TaskItemViewModel();
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TaskList.Insert(0, fileTask);
+ });
+
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+
+ var result = await fileTask.TranscodeLosseFiles(allFilesInDataFolder, skipped, cancellationTokenSource, transcodedNames);
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+ }, DispatcherPriority.Background);
+ }
+ ProgressCurrent++;
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+
+ //VP Compression
+ await Parallel.ForEachAsync(vpFiles, new ParallelOptions { MaxDegreeOfParallelism = 2, CancellationToken = cancellationTokenSource.Token }, async (file, token) =>
+ {
+ await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var vpTask = new TaskItemViewModel();
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TaskList.Insert(0, vpTask);
+ });
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+
+ await vpTask.TranscodeVP(new FileInfo(file), cancellationTokenSource, transcodedNames);
+ ProgressCurrent++;
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+ }, DispatcherPriority.Background);
+ });
+
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ // Patch effs
+ var transcodedSet = new HashSet(transcodedNames, StringComparer.OrdinalIgnoreCase);
+ HashSet forceList;
+ {
+ var effTask = new TaskItemViewModel();
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TaskList.Insert(0, effTask);
+ });
+ forceList = await effTask.PatchEffFiles(mod, transcodedSet, cancellationTokenSource);
+ }
+ ProgressCurrent++;
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ // EFF: 2nd pass (force)
+ // If PatchEffFiles report files that have not been transcoded (like when a .eff contain both
+ // uncompressed and compressed .dds files) make a 2nd pass to force transcode remaining files
+ if (forceList.Count > 0)
+ {
+ var forced = new ConcurrentBag();
+ var dataDir = mod.devMode ? mod.fullPath : mod.fullPath + Path.DirectorySeparatorChar + "data";
+
+ if (Directory.Exists(dataDir))
+ {
+ var loose2 = Directory.GetFiles(dataDir, "*.dds", SearchOption.AllDirectories)
+ .Where(f => forceList.Contains(Path.GetFileName(f).ToLowerInvariant())).ToList();
+ if (loose2.Count > 0)
+ {
+ ProgressBarMax++;
+ await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var t = new TaskItemViewModel();
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TaskList.Insert(0, t);
+ });
+ await t.TranscodeLosseFiles(loose2, 0, cancellationTokenSource, forced, forceList);
+ ProgressCurrent++;
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+ }, DispatcherPriority.Background);
+ }
+ }
+ // VP
+ var archives2 = Directory.GetFiles(mod.fullPath, "*.vp").Concat(Directory.GetFiles(mod.fullPath, "*.vpc")).ToList();
+ if (Directory.Exists(dataDir) && !mod.devMode)
+ {
+ archives2.AddRange(Directory.GetFiles(dataDir, "*.vp", SearchOption.AllDirectories));
+ archives2.AddRange(Directory.GetFiles(dataDir, "*.vpc", SearchOption.AllDirectories));
+ }
+ archives2 = archives2.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ ProgressBarMax += archives2.Count;
+
+ await Parallel.ForEachAsync(archives2, new ParallelOptions { MaxDegreeOfParallelism = Knossos.globalSettings.compressionMaxParallelism }, async (file, token) =>
+ {
+ await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var t = new TaskItemViewModel();
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ TaskList.Insert(0, t);
+ });
+ await t.TranscodeVP(new FileInfo(file), cancellationTokenSource, forced, forceList);
+ ProgressCurrent++;
+ Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax;
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+ }, DispatcherPriority.Background);
+ });
+
+ // Warn about frames that failed to convert
+ foreach (var still in forceList)
+ {
+ if (!forced.Contains(still))
+ {
+ Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.TranscodeMod()",
+ $"Frame '{still}' is referenced by a patched .eff file to type=ktx but it failed to transcode.");
+ }
+ }
+ }
+
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ //Update settings json
+ mod.modSettings.Load(mod.fullPath);
+ mod.modSettings.isCompressed = true;
+ mod.modSettings.Save();
+
+ IsCompleted = true;
+ ProgressCurrent = ProgressBarMax;
+ Info = string.Empty;
+ CancelButtonVisible = false;
+
+ if (!isSubTask && TaskViewModel.Instance!.taskQueue.Count > 0 && TaskViewModel.Instance!.taskQueue.Peek() == this)
+ {
+ TaskViewModel.Instance!.taskQueue.Dequeue();
+ }
+
+ return true;
+ }
+ else
+ {
+ throw new Exception("The task is already set, it cant be changed or re-assigned.");
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ Info = "Task Cancelled";
+ IsCompleted = false;
+ CancelButtonVisible = false;
+ //Only dispose the token if it was created locally
+ if (cancelSource == null)
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ if (!isSubTask)
+ {
+ while (TaskViewModel.Instance!.taskQueue.Count > 0 && TaskViewModel.Instance!.taskQueue.Peek() != this)
+ {
+ await Task.Delay(500);
+ }
+ if (TaskViewModel.Instance!.taskQueue.Count > 0 && TaskViewModel.Instance!.taskQueue.Peek() == this)
+ {
+ TaskViewModel.Instance!.taskQueue.Dequeue();
+ }
+ }
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Info = "Task Failed";
+ IsCompleted = false;
+ CancelButtonVisible = false;
+ cancellationTokenSource?.Cancel();
+ if (cancelSource == null)
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ if (!isSubTask)
+ {
+ while (TaskViewModel.Instance!.taskQueue.Count > 0 && TaskViewModel.Instance!.taskQueue.Peek() != this)
+ {
+ await Task.Delay(500);
+ }
+ if (TaskViewModel.Instance!.taskQueue.Count > 0 && TaskViewModel.Instance!.taskQueue.Peek() == this)
+ {
+ TaskViewModel.Instance!.taskQueue.Dequeue();
+ }
+ }
+ Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.TranscodeMod()", ex);
+ return false;
+ }
+ }
+ }
+}
diff --git a/Knossos.NET/ViewModels/Templates/Tasks/TranscodeVP.cs b/Knossos.NET/ViewModels/Templates/Tasks/TranscodeVP.cs
new file mode 100644
index 00000000..a2187d47
--- /dev/null
+++ b/Knossos.NET/ViewModels/Templates/Tasks/TranscodeVP.cs
@@ -0,0 +1,190 @@
+using Avalonia.Threading;
+using Etc2;
+using Knossos.NET.Classes;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime;
+using System.Threading;
+using System.Threading.Tasks;
+using VP.NET;
+
+namespace Knossos.NET.ViewModels
+{
+ public partial class TaskItemViewModel : ViewModelBase
+ {
+ private async Task TranscodeVP(FileInfo vpFile, CancellationTokenSource? cancelSource = null, ConcurrentBag? transcodedNames = null, HashSet? forceList = null)
+ {
+ var workFolder = "";
+ try
+ {
+ if (!TaskIsSet)
+ {
+ TaskIsSet = true;
+ ProgressBarMax = 1;
+ ProgressCurrent = 0;
+ ShowProgressText = false;
+ if (cancelSource != null)
+ {
+ cancellationTokenSource = cancelSource;
+ }
+ else
+ {
+ cancellationTokenSource = new CancellationTokenSource();
+ }
+ CancelButtonVisible = false;
+ Name = "Transcoding: " + vpFile.Name;
+
+ if (cancellationTokenSource.IsCancellationRequested)
+ {
+ throw new TaskCanceledException();
+ }
+
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.TranscodeVP()", "Starting to transcode VP file: " + vpFile.Name);
+
+ workFolder = Path.Combine(Knossos.GetKnossosLibraryPath() ?? KnUtils.GetKnossosDataFolderPath() , "temp", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(workFolder);
+ try
+ {
+ //make sure this folder is deleted at next startup if it remains
+ File.Create(Path.Combine(workFolder, "knossos_net_download.token")).Close();
+ }
+ catch { }
+
+ var vp = new VPContainer();
+ await vp.LoadVP(vpFile.FullName);
+ var changes = false;
+ if (vp.vpFiles != null && vp.vpFiles.Any())
+ {
+ var ddsFiles = vp.vpFiles[0].SearchForFileExtension(".dds");
+ if(ddsFiles.Any())
+ {
+ ProgressBarMax = ddsFiles.Count();
+ var config = Knossos.globalSettings.modEtc2TranscodeConfig ?? new Models.GlobalSettings.Etc2Config();
+ var hardwareSupport = AndroidHelper.GpuSupportsBCnTexturesOpenGL();
+ if (config.ForceBC7) hardwareSupport.bc7 = false;
+ if (config.ForceS3TC) hardwareSupport.s3tc = false;
+
+ foreach (var ddsFile in ddsFiles)
+ {
+ var nameLower = ddsFile.info.name.ToLowerInvariant();
+ await Dispatcher.UIThread.InvokeAsync(() => Info = $"{ProgressCurrent} / {ProgressBarMax} {nameLower}");
+ ProgressCurrent++;
+ if (forceList != null && !forceList.Contains(nameLower)) continue;
+
+ byte[] ddsBytes;
+ using (var inputStream = new MemoryStream())
+ {
+ await ddsFile.ReadToStream(inputStream);
+ ddsBytes = inputStream.ToArray();
+ }
+
+ var filename = Path.GetFileNameWithoutExtension(ddsFile.info.name);
+ var outputFileName = Path.Combine(workFolder, filename + ".ktx");
+ bool forceRGBA8 = filename.ToLower().Contains("normal") || filename.ToLower().Contains("reflect"); //normal and reflect textures must use ETC2 rgba8
+ if (hardwareSupport.s3tc && hardwareSupport.bc7) forceRGBA8 = false;
+ if (forceList != null) forceRGBA8 = true;
+
+ (Etc2Status status, byte[]? bytes) ktx;
+ try
+ {
+ ktx = await Task.Run(() => Etc2Transcoder.TranscodeToKtxBytes(ddsBytes, forceRgba8: forceRGBA8,
+ forceResize: config.Resize, quality: config.Quality, jobs: config.Jobs, hasS3TC : hardwareSupport.s3tc, hasBC7: hardwareSupport.bc7,
+ forceTranscodeUncompressed : forceList != null), cancellationTokenSource.Token);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ Log.Add(Log.LogSeverity.Error, "TaskViewModel.TranscodeVP()", $"Error transcoding {filename}: {ex.Message}");
+ continue;
+ }
+
+ if (ktx.status == Etc2Status.NotCompressed)
+ {
+ Log.Add(Log.LogSeverity.Information, "TaskViewModel.TranscodeVP()", $"Skipping {filename} (DDS uncompressed).");
+ continue;
+ }
+
+ if (ktx.status == Etc2Status.Skipped || ktx.bytes == null)
+ continue;
+
+ await File.WriteAllBytesAsync(outputFileName, ktx.bytes, cancellationTokenSource.Token);
+ ddsFile.Delete();
+ changes = true;
+ ddsFile.parent!.AddFile(new FileInfo(outputFileName));
+ transcodedNames?.Add(nameLower);
+ }
+ GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
+ GC.Collect(2, GCCollectionMode.Forced, blocking: true);
+ }
+ }
+ await Task.Delay(2000);
+ if (changes)
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ProgressCurrent = 0;
+ Info = "Saving...";
+ });
+ await vp.SaveAsAsync(vpFile.FullName, compressionCallback, cancellationTokenSource);
+ }
+
+ await Task.Delay(2000);
+ Log.Add(Log.LogSeverity.Information, "TaskItemViewModel.TranscodeVP()", "Transcode VP finished: " + vpFile.Name + " Processed Files: " + ProgressBarMax);
+ Info = "";
+ IsCompleted = true;
+ ProgressCurrent = ProgressBarMax;
+ if (workFolder != "" && Directory.Exists(workFolder))
+ {
+ Directory.Delete(workFolder, true);
+ }
+ return true;
+ }
+ else
+ {
+ throw new Exception("The task is already set, it cant be changed or re-assigned.");
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ /*
+ Task cancel requested by user
+ */
+ IsCompleted = false;
+ IsCancelled = true;
+ CancelButtonVisible = false;
+ Info = "Task Cancelled";
+ //Only dispose the token if it was created locally
+ if (cancelSource == null)
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ if (workFolder != "" && Directory.Exists(workFolder))
+ {
+ Directory.Delete(workFolder, true);
+ }
+ return false;
+ }
+ catch (Exception ex)
+ {
+ IsCompleted = false;
+ CancelButtonVisible = false;
+ IsCancelled = true;
+ Info = "Task Failed";
+ //Only dispose the token if it was created locally
+ if (cancelSource == null)
+ {
+ cancellationTokenSource?.Dispose();
+ }
+ if (workFolder != "" && Directory.Exists(workFolder))
+ {
+ Directory.Delete(workFolder, true);
+ }
+ Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.TranscodeVP()", ex);
+ return false;
+ }
+ }
+ }
+}
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/GlobalSettingsView.axaml b/Knossos.NET/Views/GlobalSettingsView.axaml
index ca623777..fd84712b 100644
--- a/Knossos.NET/Views/GlobalSettingsView.axaml
+++ b/Knossos.NET/Views/GlobalSettingsView.axaml
@@ -164,6 +164,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ S3TC
+ BC7
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Force S3TC
+ Force BC7
+
+
+
+
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 @@
-
+
diff --git a/VP.NET/VPFile.cs b/VP.NET/VPFile.cs
index bd47ac54..96edf02c 100644
--- a/VP.NET/VPFile.cs
+++ b/VP.NET/VPFile.cs
@@ -79,6 +79,59 @@ public VPFile CreateEmptyDirectory(string name)
return nf;
}
+ ///
+ /// Search for a single file name, recursively.
+ ///
+ ///
+ /// VPFile or null
+ public VPFile? SearchForFileName(string filename)
+ {
+ if (type == VPFileType.File)
+ {
+ if (info.name.ToLower() == filename.ToLower())
+ {
+ return this;
+ }
+ }
+ else if (type == VPFileType.Directory && files != null)
+ {
+ foreach (var f in files)
+ {
+ var found = f.SearchForFileName(filename);
+ if (found != null)
+ return found;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Get all files in a VP that have a certain extension.
+ ///
+ ///
+ /// List of VpFiles with that extension or empty list
+ public List SearchForFileExtension(string ext)
+ {
+ var list = new List();
+ if (type == VPFileType.File)
+ {
+ if (Path.GetExtension(info.name.ToLower()) == ext.ToLower())
+ list.Add(this);
+ }
+ else if (type == VPFileType.Directory && files != null)
+ {
+ foreach (var f in files)
+ {
+ var found = f.SearchForFileExtension(ext);
+ if (found.Any())
+ list.AddRange(found);
+ }
+ }
+
+ return list;
+ }
+
///
/// Adds a file into this directory
///
@@ -256,7 +309,7 @@ public async Task ReadToStream(Stream destination)
throw new Exception("Unable to open vp file in path : " + vp?.vpFilePath);
}
- var source = new FileStream(vp.vpFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize);
+ using var source = new FileStream(vp.vpFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize);
if (!source.CanRead)
{
@@ -285,13 +338,12 @@ public async Task ReadToStream(Stream destination)
{
throw new IOException("Unable to read from file");
}
+ if (!source.CanRead || !destination.CanWrite) throw new OperationCanceledException();
leftToCopy -= bytesRead;
await destination.WriteAsync(buffer, 0, bytesRead);
}
}
- source.Close();
- await source.DisposeAsync();
destination.Position = 0;
}