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