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..141b23c0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,6 +14,7 @@
+
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.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/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/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_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.Desktop/Knossos.NET.Desktop.csproj b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
index f5f5578f..6754f2fa 100644
--- a/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
+++ b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj
@@ -11,6 +11,7 @@
copyusedtruetrue
+ AnyCPU;x64
@@ -27,27 +28,108 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_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
+ etc2native.dll
+
+
+
+
+ PreserveNewest
+ etc2native.dll
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.so
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.so
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.so
+
+
+
+
+
+
+ PreserveNewest
+ libetc2native.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/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/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/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/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/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/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/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/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/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/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;
}