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 @@ copyused true true + 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; }