diff --git a/assets/TreeGeometry/065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272.glb b/assets/TreeGeometry/065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272.glb new file mode 100644 index 00000000..07168df1 Binary files /dev/null and b/assets/TreeGeometry/065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272.glb differ diff --git a/assets/TreeGeometry/074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f.glb b/assets/TreeGeometry/074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f.glb new file mode 100644 index 00000000..eca61234 Binary files /dev/null and b/assets/TreeGeometry/074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f.glb differ diff --git a/assets/TreeGeometry/09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e.glb b/assets/TreeGeometry/09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e.glb new file mode 100644 index 00000000..d5afb139 Binary files /dev/null and b/assets/TreeGeometry/09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e.glb differ diff --git a/assets/TreeGeometry/0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc.glb b/assets/TreeGeometry/0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc.glb new file mode 100644 index 00000000..eaa22026 Binary files /dev/null and b/assets/TreeGeometry/0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc.glb differ diff --git a/assets/TreeGeometry/0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8.glb b/assets/TreeGeometry/0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8.glb new file mode 100644 index 00000000..175c6fed Binary files /dev/null and b/assets/TreeGeometry/0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8.glb differ diff --git a/assets/TreeGeometry/0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b.glb b/assets/TreeGeometry/0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b.glb new file mode 100644 index 00000000..f6a8567f Binary files /dev/null and b/assets/TreeGeometry/0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b.glb differ diff --git a/assets/TreeGeometry/0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832.glb b/assets/TreeGeometry/0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832.glb new file mode 100644 index 00000000..76c9ed84 Binary files /dev/null and b/assets/TreeGeometry/0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832.glb differ diff --git a/assets/TreeGeometry/0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e.glb b/assets/TreeGeometry/0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e.glb new file mode 100644 index 00000000..0deab7ca Binary files /dev/null and b/assets/TreeGeometry/0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e.glb differ diff --git a/assets/TreeGeometry/0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361.glb b/assets/TreeGeometry/0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361.glb new file mode 100644 index 00000000..d4f195c5 Binary files /dev/null and b/assets/TreeGeometry/0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361.glb differ diff --git a/assets/TreeGeometry/0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f.glb b/assets/TreeGeometry/0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f.glb new file mode 100644 index 00000000..35d5f82c Binary files /dev/null and b/assets/TreeGeometry/0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f.glb differ diff --git a/assets/TreeGeometry/111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5.glb b/assets/TreeGeometry/111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5.glb new file mode 100644 index 00000000..661a0b41 Binary files /dev/null and b/assets/TreeGeometry/111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5.glb differ diff --git a/assets/TreeGeometry/13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637.glb b/assets/TreeGeometry/13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637.glb new file mode 100644 index 00000000..55be8f6a Binary files /dev/null and b/assets/TreeGeometry/13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637.glb differ diff --git a/assets/TreeGeometry/19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119.glb b/assets/TreeGeometry/19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119.glb new file mode 100644 index 00000000..4bb48b27 Binary files /dev/null and b/assets/TreeGeometry/19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119.glb differ diff --git a/assets/TreeGeometry/1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862.glb b/assets/TreeGeometry/1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862.glb new file mode 100644 index 00000000..4bd102f7 Binary files /dev/null and b/assets/TreeGeometry/1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862.glb differ diff --git a/assets/TreeGeometry/1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149.glb b/assets/TreeGeometry/1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149.glb new file mode 100644 index 00000000..7ed3e030 Binary files /dev/null and b/assets/TreeGeometry/1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149.glb differ diff --git a/assets/TreeGeometry/1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e.glb b/assets/TreeGeometry/1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e.glb new file mode 100644 index 00000000..a358dba1 Binary files /dev/null and b/assets/TreeGeometry/1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e.glb differ diff --git a/assets/TreeGeometry/2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d.glb b/assets/TreeGeometry/2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d.glb new file mode 100644 index 00000000..51518782 Binary files /dev/null and b/assets/TreeGeometry/2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d.glb differ diff --git a/assets/TreeGeometry/23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436.glb b/assets/TreeGeometry/23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436.glb new file mode 100644 index 00000000..ffc21ca7 Binary files /dev/null and b/assets/TreeGeometry/23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436.glb differ diff --git a/assets/TreeGeometry/25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16.glb b/assets/TreeGeometry/25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16.glb new file mode 100644 index 00000000..80c3859d Binary files /dev/null and b/assets/TreeGeometry/25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16.glb differ diff --git a/assets/TreeGeometry/265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1.glb b/assets/TreeGeometry/265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1.glb new file mode 100644 index 00000000..6f99f0e3 Binary files /dev/null and b/assets/TreeGeometry/265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1.glb differ diff --git a/assets/TreeGeometry/2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a.glb b/assets/TreeGeometry/2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a.glb new file mode 100644 index 00000000..78355d0a Binary files /dev/null and b/assets/TreeGeometry/2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a.glb differ diff --git a/assets/TreeGeometry/2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f.glb b/assets/TreeGeometry/2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f.glb new file mode 100644 index 00000000..d088d21e Binary files /dev/null and b/assets/TreeGeometry/2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f.glb differ diff --git a/assets/TreeGeometry/2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742.glb b/assets/TreeGeometry/2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742.glb new file mode 100644 index 00000000..6fded8fb Binary files /dev/null and b/assets/TreeGeometry/2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742.glb differ diff --git a/assets/TreeGeometry/2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d.glb b/assets/TreeGeometry/2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d.glb new file mode 100644 index 00000000..a63eaf99 Binary files /dev/null and b/assets/TreeGeometry/2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d.glb differ diff --git a/assets/TreeGeometry/34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8.glb b/assets/TreeGeometry/34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8.glb new file mode 100644 index 00000000..8cbb16e1 Binary files /dev/null and b/assets/TreeGeometry/34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8.glb differ diff --git a/assets/TreeGeometry/36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9.glb b/assets/TreeGeometry/36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9.glb new file mode 100644 index 00000000..d6c413d7 Binary files /dev/null and b/assets/TreeGeometry/36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9.glb differ diff --git a/assets/TreeGeometry/3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe.glb b/assets/TreeGeometry/3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe.glb new file mode 100644 index 00000000..6c48f4a9 Binary files /dev/null and b/assets/TreeGeometry/3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe.glb differ diff --git a/assets/TreeGeometry/41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225.glb b/assets/TreeGeometry/41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225.glb new file mode 100644 index 00000000..d21e6793 Binary files /dev/null and b/assets/TreeGeometry/41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225.glb differ diff --git a/assets/TreeGeometry/44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0.glb b/assets/TreeGeometry/44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0.glb new file mode 100644 index 00000000..6d8100ba Binary files /dev/null and b/assets/TreeGeometry/44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0.glb differ diff --git a/assets/TreeGeometry/4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6.glb b/assets/TreeGeometry/4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6.glb new file mode 100644 index 00000000..660dd33b Binary files /dev/null and b/assets/TreeGeometry/4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6.glb differ diff --git a/assets/TreeGeometry/50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5.glb b/assets/TreeGeometry/50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5.glb new file mode 100644 index 00000000..741a543e Binary files /dev/null and b/assets/TreeGeometry/50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5.glb differ diff --git a/assets/TreeGeometry/55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255.glb b/assets/TreeGeometry/55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255.glb new file mode 100644 index 00000000..977d7dbc Binary files /dev/null and b/assets/TreeGeometry/55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255.glb differ diff --git a/assets/TreeGeometry/55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116.glb b/assets/TreeGeometry/55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116.glb new file mode 100644 index 00000000..9d1d44d6 Binary files /dev/null and b/assets/TreeGeometry/55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116.glb differ diff --git a/assets/TreeGeometry/5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a.glb b/assets/TreeGeometry/5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a.glb new file mode 100644 index 00000000..f8c6111b Binary files /dev/null and b/assets/TreeGeometry/5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a.glb differ diff --git a/assets/TreeGeometry/61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3.glb b/assets/TreeGeometry/61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3.glb new file mode 100644 index 00000000..a4b94721 Binary files /dev/null and b/assets/TreeGeometry/61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3.glb differ diff --git a/assets/TreeGeometry/6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d.glb b/assets/TreeGeometry/6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d.glb new file mode 100644 index 00000000..8522f66b Binary files /dev/null and b/assets/TreeGeometry/6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d.glb differ diff --git a/assets/TreeGeometry/65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67.glb b/assets/TreeGeometry/65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67.glb new file mode 100644 index 00000000..58292054 Binary files /dev/null and b/assets/TreeGeometry/65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67.glb differ diff --git a/assets/TreeGeometry/6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77.glb b/assets/TreeGeometry/6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77.glb new file mode 100644 index 00000000..8050279e Binary files /dev/null and b/assets/TreeGeometry/6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77.glb differ diff --git a/assets/TreeGeometry/66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04.glb b/assets/TreeGeometry/66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04.glb new file mode 100644 index 00000000..9649f81c Binary files /dev/null and b/assets/TreeGeometry/66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04.glb differ diff --git a/assets/TreeGeometry/6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e.glb b/assets/TreeGeometry/6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e.glb new file mode 100644 index 00000000..d29b5766 Binary files /dev/null and b/assets/TreeGeometry/6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e.glb differ diff --git a/assets/TreeGeometry/73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa.glb b/assets/TreeGeometry/73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa.glb new file mode 100644 index 00000000..a26ed433 Binary files /dev/null and b/assets/TreeGeometry/73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa.glb differ diff --git a/assets/TreeGeometry/75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6.glb b/assets/TreeGeometry/75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6.glb new file mode 100644 index 00000000..246e40cd Binary files /dev/null and b/assets/TreeGeometry/75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6.glb differ diff --git a/assets/TreeGeometry/80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631.glb b/assets/TreeGeometry/80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631.glb new file mode 100644 index 00000000..18e5ee47 Binary files /dev/null and b/assets/TreeGeometry/80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631.glb differ diff --git a/assets/TreeGeometry/82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462.glb b/assets/TreeGeometry/82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462.glb new file mode 100644 index 00000000..e2389531 Binary files /dev/null and b/assets/TreeGeometry/82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462.glb differ diff --git a/assets/TreeGeometry/86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655.glb b/assets/TreeGeometry/86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655.glb new file mode 100644 index 00000000..83ffbf62 Binary files /dev/null and b/assets/TreeGeometry/86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655.glb differ diff --git a/assets/TreeGeometry/8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28.glb b/assets/TreeGeometry/8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28.glb new file mode 100644 index 00000000..2de31a09 Binary files /dev/null and b/assets/TreeGeometry/8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28.glb differ diff --git a/assets/TreeGeometry/8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0.glb b/assets/TreeGeometry/8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0.glb new file mode 100644 index 00000000..4f498dce Binary files /dev/null and b/assets/TreeGeometry/8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0.glb differ diff --git a/assets/TreeGeometry/8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb.glb b/assets/TreeGeometry/8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb.glb new file mode 100644 index 00000000..baa4c676 Binary files /dev/null and b/assets/TreeGeometry/8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb.glb differ diff --git a/assets/TreeGeometry/93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354.glb b/assets/TreeGeometry/93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354.glb new file mode 100644 index 00000000..dd363edb Binary files /dev/null and b/assets/TreeGeometry/93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354.glb differ diff --git a/assets/TreeGeometry/97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af.glb b/assets/TreeGeometry/97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af.glb new file mode 100644 index 00000000..6cd56ff0 Binary files /dev/null and b/assets/TreeGeometry/97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af.glb differ diff --git a/assets/TreeGeometry/9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7.glb b/assets/TreeGeometry/9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7.glb new file mode 100644 index 00000000..7ddc6d72 Binary files /dev/null and b/assets/TreeGeometry/9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7.glb differ diff --git a/assets/TreeGeometry/9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741.glb b/assets/TreeGeometry/9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741.glb new file mode 100644 index 00000000..969de75f Binary files /dev/null and b/assets/TreeGeometry/9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741.glb differ diff --git a/assets/TreeGeometry/README.md b/assets/TreeGeometry/README.md new file mode 100644 index 00000000..1c8c6a8d --- /dev/null +++ b/assets/TreeGeometry/README.md @@ -0,0 +1,27 @@ +# Native tree geometry + +These 87 GLBs are offline SpeedTreeRT extractions of this workspace's existing +SPT assets, not procedural replacements. Their embedded bark/composite textures +and meshes are game artwork: the same internal-use/licensing restrictions as +`assets/Tree` apply. Do not treat them as freely redistributable open-source art. + +`manifest.json` maps original SPT SHA-256 to the GLB filename and SHA-256. +Both hashes are checked by `script/stage_tree_geometry.mjs`; the runtime also +verifies both before loading. The source conversion index lives in the sibling +`metin2-godot-main/generated/content-v1/40250-full-unpacked-final` workspace. +No proprietary Windows DLL is shipped or loaded by the Mac client. + +Coordinates are already Godot Y-up metres. Keep original height, origin, UVs, +branches, fronds and leaf cards; do not apply the former proxy's 12m scale. +Leaf materials use double-sided alpha scissor with cutoff 0.3. + +This is real **static** geometry: full SpeedTree wind, camera-facing leaf +clusters and native LOD transitions are not reconstructed by these GLBs. + +Validation: all 87 species import; all 81 species referenced by current static +AreaData have a conversion; six principal maps render 2,092 native trees with +zero proxies. The 31 legacy SPTs under `Zone/ymir work/zone/b/tree` have different +source hashes and are not converted or aliased to these meshes. They are not +referenced by current tree property records. One unused historical property +references missing `christmastree.spt`; the actual `christmastree1/2/3.spt` +variants are converted. Seasonal event spawning is separate from static maps. diff --git a/assets/TreeGeometry/a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900.glb b/assets/TreeGeometry/a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900.glb new file mode 100644 index 00000000..c6dd2057 Binary files /dev/null and b/assets/TreeGeometry/a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900.glb differ diff --git a/assets/TreeGeometry/a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2.glb b/assets/TreeGeometry/a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2.glb new file mode 100644 index 00000000..8d0c7653 Binary files /dev/null and b/assets/TreeGeometry/a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2.glb differ diff --git a/assets/TreeGeometry/a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5.glb b/assets/TreeGeometry/a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5.glb new file mode 100644 index 00000000..8deed299 Binary files /dev/null and b/assets/TreeGeometry/a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5.glb differ diff --git a/assets/TreeGeometry/ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f.glb b/assets/TreeGeometry/ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f.glb new file mode 100644 index 00000000..10b00969 Binary files /dev/null and b/assets/TreeGeometry/ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f.glb differ diff --git a/assets/TreeGeometry/af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff.glb b/assets/TreeGeometry/af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff.glb new file mode 100644 index 00000000..ca5cb5d7 Binary files /dev/null and b/assets/TreeGeometry/af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff.glb differ diff --git a/assets/TreeGeometry/b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad.glb b/assets/TreeGeometry/b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad.glb new file mode 100644 index 00000000..3c69903a Binary files /dev/null and b/assets/TreeGeometry/b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad.glb differ diff --git a/assets/TreeGeometry/bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da.glb b/assets/TreeGeometry/bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da.glb new file mode 100644 index 00000000..63e0f8f7 Binary files /dev/null and b/assets/TreeGeometry/bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da.glb differ diff --git a/assets/TreeGeometry/c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22.glb b/assets/TreeGeometry/c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22.glb new file mode 100644 index 00000000..d8f2cfe3 Binary files /dev/null and b/assets/TreeGeometry/c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22.glb differ diff --git a/assets/TreeGeometry/cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3.glb b/assets/TreeGeometry/cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3.glb new file mode 100644 index 00000000..b11a01ed Binary files /dev/null and b/assets/TreeGeometry/cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3.glb differ diff --git a/assets/TreeGeometry/cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9.glb b/assets/TreeGeometry/cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9.glb new file mode 100644 index 00000000..d40fe2f8 Binary files /dev/null and b/assets/TreeGeometry/cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9.glb differ diff --git a/assets/TreeGeometry/d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a.glb b/assets/TreeGeometry/d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a.glb new file mode 100644 index 00000000..5bf492b4 Binary files /dev/null and b/assets/TreeGeometry/d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a.glb differ diff --git a/assets/TreeGeometry/d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4.glb b/assets/TreeGeometry/d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4.glb new file mode 100644 index 00000000..91cc257e Binary files /dev/null and b/assets/TreeGeometry/d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4.glb differ diff --git a/assets/TreeGeometry/d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953.glb b/assets/TreeGeometry/d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953.glb new file mode 100644 index 00000000..9d481ade Binary files /dev/null and b/assets/TreeGeometry/d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953.glb differ diff --git a/assets/TreeGeometry/d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26.glb b/assets/TreeGeometry/d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26.glb new file mode 100644 index 00000000..60c5de39 Binary files /dev/null and b/assets/TreeGeometry/d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26.glb differ diff --git a/assets/TreeGeometry/d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2.glb b/assets/TreeGeometry/d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2.glb new file mode 100644 index 00000000..e39a8104 Binary files /dev/null and b/assets/TreeGeometry/d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2.glb differ diff --git a/assets/TreeGeometry/d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1.glb b/assets/TreeGeometry/d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1.glb new file mode 100644 index 00000000..b75cad16 Binary files /dev/null and b/assets/TreeGeometry/d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1.glb differ diff --git a/assets/TreeGeometry/d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7.glb b/assets/TreeGeometry/d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7.glb new file mode 100644 index 00000000..59675433 Binary files /dev/null and b/assets/TreeGeometry/d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7.glb differ diff --git a/assets/TreeGeometry/dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534.glb b/assets/TreeGeometry/dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534.glb new file mode 100644 index 00000000..cee0cade Binary files /dev/null and b/assets/TreeGeometry/dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534.glb differ diff --git a/assets/TreeGeometry/dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e.glb b/assets/TreeGeometry/dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e.glb new file mode 100644 index 00000000..88035994 Binary files /dev/null and b/assets/TreeGeometry/dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e.glb differ diff --git a/assets/TreeGeometry/de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48.glb b/assets/TreeGeometry/de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48.glb new file mode 100644 index 00000000..0cda3142 Binary files /dev/null and b/assets/TreeGeometry/de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48.glb differ diff --git a/assets/TreeGeometry/de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e.glb b/assets/TreeGeometry/de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e.glb new file mode 100644 index 00000000..f0f33305 Binary files /dev/null and b/assets/TreeGeometry/de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e.glb differ diff --git a/assets/TreeGeometry/e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b.glb b/assets/TreeGeometry/e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b.glb new file mode 100644 index 00000000..614bda46 Binary files /dev/null and b/assets/TreeGeometry/e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b.glb differ diff --git a/assets/TreeGeometry/e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36.glb b/assets/TreeGeometry/e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36.glb new file mode 100644 index 00000000..dc945715 Binary files /dev/null and b/assets/TreeGeometry/e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36.glb differ diff --git a/assets/TreeGeometry/e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823.glb b/assets/TreeGeometry/e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823.glb new file mode 100644 index 00000000..5164a173 Binary files /dev/null and b/assets/TreeGeometry/e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823.glb differ diff --git a/assets/TreeGeometry/e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828.glb b/assets/TreeGeometry/e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828.glb new file mode 100644 index 00000000..f71807ec Binary files /dev/null and b/assets/TreeGeometry/e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828.glb differ diff --git a/assets/TreeGeometry/e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3.glb b/assets/TreeGeometry/e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3.glb new file mode 100644 index 00000000..853dfc0e Binary files /dev/null and b/assets/TreeGeometry/e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3.glb differ diff --git a/assets/TreeGeometry/e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36.glb b/assets/TreeGeometry/e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36.glb new file mode 100644 index 00000000..afd97273 Binary files /dev/null and b/assets/TreeGeometry/e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36.glb differ diff --git a/assets/TreeGeometry/e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c.glb b/assets/TreeGeometry/e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c.glb new file mode 100644 index 00000000..94bc7a25 Binary files /dev/null and b/assets/TreeGeometry/e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c.glb differ diff --git a/assets/TreeGeometry/ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5.glb b/assets/TreeGeometry/ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5.glb new file mode 100644 index 00000000..aa56db74 Binary files /dev/null and b/assets/TreeGeometry/ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5.glb differ diff --git a/assets/TreeGeometry/eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086.glb b/assets/TreeGeometry/eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086.glb new file mode 100644 index 00000000..7ab58254 Binary files /dev/null and b/assets/TreeGeometry/eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086.glb differ diff --git a/assets/TreeGeometry/f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd.glb b/assets/TreeGeometry/f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd.glb new file mode 100644 index 00000000..76b43015 Binary files /dev/null and b/assets/TreeGeometry/f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd.glb differ diff --git a/assets/TreeGeometry/f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db.glb b/assets/TreeGeometry/f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db.glb new file mode 100644 index 00000000..20c22ad5 Binary files /dev/null and b/assets/TreeGeometry/f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db.glb differ diff --git a/assets/TreeGeometry/f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d.glb b/assets/TreeGeometry/f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d.glb new file mode 100644 index 00000000..44adf345 Binary files /dev/null and b/assets/TreeGeometry/f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d.glb differ diff --git a/assets/TreeGeometry/fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59.glb b/assets/TreeGeometry/fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59.glb new file mode 100644 index 00000000..26ceda8a Binary files /dev/null and b/assets/TreeGeometry/fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59.glb differ diff --git a/assets/TreeGeometry/fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f.glb b/assets/TreeGeometry/fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f.glb new file mode 100644 index 00000000..0ab9ca8e Binary files /dev/null and b/assets/TreeGeometry/fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f.glb differ diff --git a/assets/TreeGeometry/manifest.json b/assets/TreeGeometry/manifest.json new file mode 100644 index 00000000..a11a0a78 --- /dev/null +++ b/assets/TreeGeometry/manifest.json @@ -0,0 +1,963 @@ +{ + "schema_version": 1, + "coordinates": "godot_y_up_meters", + "trees": { + "1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e": { + "source": "Tree/ymir work/tree/b1_baobab_rt.spt", + "source_sha256": "1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e", + "glb": "1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e.glb", + "glb_sha256": "642c31b558023112fc6c5fba9955c235d39d97a7c05eb56613bd76a4cf33e83e", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741": { + "source": "Tree/ymir work/tree/b1_baobab_rt2.spt", + "source_sha256": "9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741", + "glb": "9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741.glb", + "glb_sha256": "4a4d6013668a084dc3b00d44e9cc7474457ddd1f06e64229a348ff8f0a445326", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48": { + "source": "Tree/ymir work/tree/b1_beech_rt.spt", + "source_sha256": "de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48", + "glb": "de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48.glb", + "glb_sha256": "18f49b44e9b435f91da8b75958aab7aef4b0d368301da7b3789f51e0570481ac", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9": { + "source": "Tree/ymir work/tree/b1_beech_rt2.spt", + "source_sha256": "cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9", + "glb": "cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9.glb", + "glb_sha256": "9da1a6ab558c70dda232c36a2bd85bb710d24031149dac0198af3532b0bd4145", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7": { + "source": "Tree/ymir work/tree/b1_beech_rt3.spt", + "source_sha256": "9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7", + "glb": "9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7.glb", + "glb_sha256": "edf0fbac525cfc48f87527aa25d3f49df6f010ee51f56ae78e0ac69d00aa4ba5", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16": { + "source": "Tree/ymir work/tree/b1_beech_rt4.spt", + "source_sha256": "25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16", + "glb": "25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16.glb", + "glb_sha256": "edc5c99aab45da766baa28ee402086b2b107326ec52d81b3324c06ad38def6ee", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04": { + "source": "Tree/ymir work/tree/b1_montereycypress_rt.spt", + "source_sha256": "66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04", + "glb": "66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04.glb", + "glb_sha256": "9abdfc7f413b5aa4117f36106720dc979ebfa4c647c28a833904aa4d9e713466", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8": { + "source": "Tree/ymir work/tree/b1_montereycypress_rt2.spt", + "source_sha256": "0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8", + "glb": "0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8.glb", + "glb_sha256": "acd4675f30e805d5543c17f0a7a52fe37f31f6e67874b196b7395997e1ea8e83", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db": { + "source": "Tree/ymir work/tree/b1_montereycypress_rt3.spt", + "source_sha256": "f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db", + "glb": "f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db.glb", + "glb_sha256": "56effe5240c0cf44f74c033412fa2bf25a8329be74c66a62da3d2288ae24ab10", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1": { + "source": "Tree/ymir work/tree/b1_montereycypress_rt4.spt", + "source_sha256": "d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1", + "glb": "d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1.glb", + "glb_sha256": "d371eba315defbfb6956cc8cfbd26dde7ff9240d21f832c37a6fc63559bfcca2", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3": { + "source": "Tree/ymir work/tree/b1_montereycypress_rt5.spt", + "source_sha256": "cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3", + "glb": "cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3.glb", + "glb_sha256": "28c7a3e30c21d3874247e10a0bf6107de1775d29b0effa664edeb743c036f953", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a": { + "source": "Tree/ymir work/tree/b1_pagodatree_rt.spt", + "source_sha256": "2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a", + "glb": "2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a.glb", + "glb_sha256": "0af3909bf5fe30ae77f918e7641e12791b8c2d3f40ec576aa375b3005bb8f528", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953": { + "source": "Tree/ymir work/tree/b1_pagodatree_rt2.spt", + "source_sha256": "d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953", + "glb": "d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953.glb", + "glb_sha256": "a6546ab05a6722bf2eaefc0b23ae399d97050a280631488bb82d651ed131ea52", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900": { + "source": "Tree/ymir work/tree/b1_pagodatree_rt3.spt", + "source_sha256": "a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900", + "glb": "a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900.glb", + "glb_sha256": "fd60281f1384d80b51f838ee512e5d7506fef17d8ad9c28c962a4970e5e02be6", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149": { + "source": "Tree/ymir work/tree/b1_sassafras_rt_fall.spt", + "source_sha256": "1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149", + "glb": "1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149.glb", + "glb_sha256": "69ba5cd343cb193e12d5fa9203356e08e5536e6a0083650ca4be88f87472fcf8", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a": { + "source": "Tree/ymir work/tree/b1_sassafras_rt_fall2.spt", + "source_sha256": "5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a", + "glb": "5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a.glb", + "glb_sha256": "14cb7ad036580d3387112e2098d52ee6815d158c533ed52f3db0604a00c33b09", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2": { + "source": "Tree/ymir work/tree/b2_cedaroflebanon_rt.spt", + "source_sha256": "d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2", + "glb": "d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2.glb", + "glb_sha256": "d8df7faf73265c6a5b2a725cfacab421b609cd9a4bd2bad41997e349e9ee4fa7", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa": { + "source": "Tree/ymir work/tree/b2_cedaroflebanon_rt2.spt", + "source_sha256": "73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa", + "glb": "73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa.glb", + "glb_sha256": "c6c15c69d0b60f232293e74f8da191b3ae62f4a69fba61d0e7fe94008c51176e", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f": { + "source": "Tree/ymir work/tree/b2_cedaroflebanon_rt3.spt", + "source_sha256": "fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f", + "glb": "fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f.glb", + "glb_sha256": "81e2b1c1cd753ba17e8d875bc5ad0104c0f2a8cc1831d4decbc64d7a82fb1202", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt.spt", + "source_sha256": "50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5", + "glb": "50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5.glb", + "glb_sha256": "d632d027f5e228e6d85a986068ada05996461692623dc082569b9078965af7dd", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt2.spt", + "source_sha256": "d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a", + "glb": "d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a.glb", + "glb_sha256": "ce88fcfb1f04048912665592e0ad4da69679163c23b8b2f541641bad7e319f2d", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt3.spt", + "source_sha256": "065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272", + "glb": "065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272.glb", + "glb_sha256": "aea108dfb8fcaf7021b54df8c27a194ac5486fac08fb32dbc15c00b7834d2596", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt4.spt", + "source_sha256": "61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3", + "glb": "61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3.glb", + "glb_sha256": "fcd95e7569a5a6d8af5e987f3764d520d941fa518827ae247514b003c2de5a87", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt5.spt", + "source_sha256": "f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd", + "glb": "f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd.glb", + "glb_sha256": "0824f3fc789c420d6eb543cf932f617eabb8f01437e5bf6ef9aacf116e0eca83", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt_winter.spt", + "source_sha256": "80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631", + "glb": "80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631.glb", + "glb_sha256": "f2a4fa095e0d7ca96a49afc371b9f9189a5b370ca4543827fc3098971b779a31", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb": { + "source": "Tree/ymir work/tree/b2_ivyspy_rt_winter2.spt", + "source_sha256": "8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb", + "glb": "8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb.glb", + "glb_sha256": "97a8e81c46cf1f5dbe8084535e17286c7df07e7b71d61ae7c591adff1dd62fda", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59": { + "source": "Tree/ymir work/tree/b2_japanesemaple_rt_fall.spt", + "source_sha256": "fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59", + "glb": "fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59.glb", + "glb_sha256": "34831b921865e35889704938333c80d69daaba09762e4fe8351c673811053dea", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655": { + "source": "Tree/ymir work/tree/b2_japanesemaple_rt_fall2.spt", + "source_sha256": "86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655", + "glb": "86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655.glb", + "glb_sha256": "f38ffdbebb6f60e4e92d3b8993cc3001c502a8223c72bfb3688812d9217a67b2", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637": { + "source": "Tree/ymir work/tree/b2_redoak_rt.spt", + "source_sha256": "13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637", + "glb": "13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637.glb", + "glb_sha256": "6fc61c59dc838a768f10b749a9b7ce1ea659e4a9d6f9fc6ee6c887c0532adc6c", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116": { + "source": "Tree/ymir work/tree/b2_redoak_rt2.spt", + "source_sha256": "55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116", + "glb": "55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116.glb", + "glb_sha256": "3200e730f4551792a504f20e4046e7bcf466e1e51d90229c9328b3ea63bc47cb", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22": { + "source": "Tree/ymir work/tree/b2_redoak_rt3.spt", + "source_sha256": "c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22", + "glb": "c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22.glb", + "glb_sha256": "6781839f9fcdcdbae7b156b5708436e2580781a11bb784ff11f5f088a841471a", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832": { + "source": "Tree/ymir work/tree/b2_redoak_rt4.spt", + "source_sha256": "0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832", + "glb": "0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832.glb", + "glb_sha256": "febe8dcbe1880cec8f91dc5844a55749c8d183e86c1b581d07440f6bf38ab794", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086": { + "source": "Tree/ymir work/tree/b3_beech_rt.spt", + "source_sha256": "eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086", + "glb": "eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086.glb", + "glb_sha256": "1d29e58f3e04b4dbfe4cbfb91403bddcfed2af0f282636f506d358b5f15cccb1", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67": { + "source": "Tree/ymir work/tree/b3_beech_rt2.spt", + "source_sha256": "65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67", + "glb": "65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67.glb", + "glb_sha256": "2bdbe584e694ec4516803ac0966f741cf39920f20c4692b6f5f4496e5fff0b06", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e": { + "source": "Tree/ymir work/tree/b3_beech_rt3.spt", + "source_sha256": "09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e", + "glb": "09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e.glb", + "glb_sha256": "cae93d33e0849b769cb9070bb094239a2d68c33e22942df24836190757c3cd98", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36": { + "source": "Tree/ymir work/tree/b3_beech_rt_fall.spt", + "source_sha256": "e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36", + "glb": "e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36.glb", + "glb_sha256": "59e6e7c3378af28c44a3293b1bd026023cbcc9c5447be1684e63a88d9b3691f8", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0": { + "source": "Tree/ymir work/tree/b3_beech_rt_fall2.spt", + "source_sha256": "8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0", + "glb": "8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0.glb", + "glb_sha256": "3e684dc00afd7451b28ed3f89a00baa7ea6c9d45c442caad332b9596ed0b872c", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77": { + "source": "Tree/ymir work/tree/b3_pagodatree_rt_winter.spt", + "source_sha256": "6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77", + "glb": "6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77.glb", + "glb_sha256": "bec163b2c1fa99b26d97bad86155a401ab19c067b2acb160a747fceb5b168226", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5": { + "source": "Tree/ymir work/tree/b3_pagodatree_rt_winter2.spt", + "source_sha256": "ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5", + "glb": "ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5.glb", + "glb_sha256": "63aa6ef7fe8181bc1877e86a6b3f1f3bd617a2af303208006289c8e68a76b859", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b": { + "source": "Tree/ymir work/tree/b3_shingleoak_rt.spt", + "source_sha256": "e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b", + "glb": "e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b.glb", + "glb_sha256": "54bb8cf813b9c6f05f14ea7dbb39689a3a26f9737e7526969c2bea070b8bf78f", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26": { + "source": "Tree/ymir work/tree/b3_shingleoak_rt2.spt", + "source_sha256": "d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26", + "glb": "d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26.glb", + "glb_sha256": "6b85b0a649472c7268791a0adce367099b4fbb783d07ad3a508e0aa88d77c331", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742": { + "source": "Tree/ymir work/tree/b3_shingleoak_rt3.spt", + "source_sha256": "2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742", + "glb": "2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742.glb", + "glb_sha256": "3a3fc8e8afa93b2a65806fd5d6eb625a05426c6f8a99c50a77ac1c197bf9a6f5", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc": { + "source": "Tree/ymir work/tree/b3_shingleoak_rt4.spt", + "source_sha256": "0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc", + "glb": "0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc.glb", + "glb_sha256": "39dc26f0713574b98e8bba0a26ff448bd6fabd97c5b66880f155516f536d35a4", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534": { + "source": "Tree/ymir work/tree/b3_shingleoak_rt5.spt", + "source_sha256": "dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534", + "glb": "dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534.glb", + "glb_sha256": "08dc609a9aa5b16ce943dfd6bc63d2db45daf3bc7252fd6d1ee58a7ce6449685", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f": { + "source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers.spt", + "source_sha256": "0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f", + "glb": "0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f.glb", + "glb_sha256": "580a3ff0582a7723250650e8b0828912000faf494afb25ace42578e0d1397bb8", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe": { + "source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers2.spt", + "source_sha256": "3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe", + "glb": "3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe.glb", + "glb_sha256": "2483acf297ce1f35e3ef297ff14e84c33b16bfae885fb04f373a601ad41cfab6", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2": { + "source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers3.spt", + "source_sha256": "a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2", + "glb": "a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2.glb", + "glb_sha256": "0d5fe2e83f4621f6cefbbf8e645306af026c540364d05d91e2757d8d4775ceff", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0": { + "source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers4.spt", + "source_sha256": "44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0", + "glb": "44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0.glb", + "glb_sha256": "1efd17b58f6dd39b3df3e7de84d20a1d4740e8d53c05a08080a3e335619e6ac8", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7": { + "source": "Tree/ymir work/tree/christmastree1.spt", + "source_sha256": "d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7", + "glb": "d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7.glb", + "glb_sha256": "6836fef2133fbc14a4ac17f3762cfe64b596e2cc1dbae818c80fe4eb421a510f", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d": { + "source": "Tree/ymir work/tree/christmastree2.spt", + "source_sha256": "6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d", + "glb": "6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d.glb", + "glb_sha256": "451bd2edca71c9aa5c658ce8bc1353b7315530220f1d1458c037d5ea007b2794", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f": { + "source": "Tree/ymir work/tree/christmastree3.spt", + "source_sha256": "074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f", + "glb": "074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f.glb", + "glb_sha256": "6445e98dd9760e3371dbcd13673b539b2c6be6450dc883605c50b3d5fac3f825", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b": { + "source": "Tree/ymir work/tree/n1_baobab_rt_winter.spt", + "source_sha256": "0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b", + "glb": "0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b.glb", + "glb_sha256": "d49a2346357486cab5368ecb1a32a8e728bd45318b301b86755c41d481f3712f", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e": { + "source": "Tree/ymir work/tree/n1_beech_rt_winter.spt", + "source_sha256": "0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e", + "glb": "0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e.glb", + "glb_sha256": "031cde20e4c46d5f75cda94fd783834614d31584f7af36e9be2218dabc6a0cbb", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3": { + "source": "Tree/ymir work/tree/n1_beech_rt_winter02.spt", + "source_sha256": "e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3", + "glb": "e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3.glb", + "glb_sha256": "f542b8c33350569b80f2a3c88fc1db22be93ed51fbcfe46654d2b29fccaa413a", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225": { + "source": "Tree/ymir work/tree/n1_beech_rt_winter03.spt", + "source_sha256": "41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225", + "glb": "41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225.glb", + "glb_sha256": "fd2fd865a012ba6f53b0bdd0eea8971c7858826cc4cbb08d3a6db1becf027fc0", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad": { + "source": "Tree/ymir work/tree/n1_coloradobluespruce_rt_01.spt", + "source_sha256": "b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad", + "glb": "b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad.glb", + "glb_sha256": "82d906bb624b0041c3d687e3af51cff8ed524ba75e6432a5f293554d5d4ff137", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1": { + "source": "Tree/ymir work/tree/n1_coloradobluespruce_rt_02.spt", + "source_sha256": "265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1", + "glb": "265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1.glb", + "glb_sha256": "6265bd35d1da9f9daa34a15b34dd9cadc5af9d6346c0b9104b2c3d582c510225", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d": { + "source": "Tree/ymir work/tree/n1_coloradobluespruce_rt_03.spt", + "source_sha256": "2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d", + "glb": "2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d.glb", + "glb_sha256": "cbeb5930c57e2dbefbe9c50987275e7abc2b1ac9aa9cf190f60de71dae75f52b", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6": { + "source": "Tree/ymir work/tree/n1_commonolive_rt_winter.spt", + "source_sha256": "4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6", + "glb": "4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6.glb", + "glb_sha256": "a2fb958788a84301f9008e4efe1ea9994314d28b4b3954649c64b3033bf912c0", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c": { + "source": "Tree/ymir work/tree/n1_montereycypress_rt_winter_01.spt", + "source_sha256": "e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c", + "glb": "e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c.glb", + "glb_sha256": "9dc165cb8f6417f9e27486db714809722b5e85ff4ed827b21a0fe1d52feee89f", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da": { + "source": "Tree/ymir work/tree/n1_montereycypress_rt_winter_02.spt", + "source_sha256": "bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da", + "glb": "bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da.glb", + "glb_sha256": "f64d701e3ec752f566eef179ed62d613fd20d7dcc0bd86dcb95c1e3a0ef56c86", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862": { + "source": "Tree/ymir work/tree/n1_montereycypress_rt_winter_03.spt", + "source_sha256": "1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862", + "glb": "1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862.glb", + "glb_sha256": "f80e080f78042b751116ef056edf98501922de0bb46c6e0efb950e17115afbbe", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9": { + "source": "Tree/ymir work/tree/n1_tulip_rt_winter_01.spt", + "source_sha256": "36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9", + "glb": "36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9.glb", + "glb_sha256": "ce0f23b6d5931ebeea06862a4ffef37b2274dc0b8643ec2f5768f1b0df3ae858", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 1, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361": { + "source": "Tree/ymir work/tree/n1_whitepine_rt_low_01.spt", + "source_sha256": "0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361", + "glb": "0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361.glb", + "glb_sha256": "48bd12ddffdc0aee165a29bfc6fd0dcbfe1366aec5542bcee102bf8d5d82c3e2", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f": { + "source": "Tree/ymir work/tree/n1_whitepine_rt_low_02.spt", + "source_sha256": "ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f", + "glb": "ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f.glb", + "glb_sha256": "fa1fe6e118c91b6d1276b17ed05297eed6349e4c29726e11a51de48252dbf787", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8": { + "source": "Tree/ymir work/tree/n2_aloevera_rt_flowers_01.spt", + "source_sha256": "34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8", + "glb": "34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8.glb", + "glb_sha256": "96d7b310fa8d13e910b11e5524a1a30886e21c9e715290c64d1df2e9b4298d17", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36": { + "source": "Tree/ymir work/tree/n2_aloevera_rt_flowers_02.spt", + "source_sha256": "e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36", + "glb": "e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36.glb", + "glb_sha256": "968300bd31c235557926343e46d7335ae75644aff6d0ca58f3a3e4a1963c5e51", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d": { + "source": "Tree/ymir work/tree/n2_bananatree_rt_01.spt", + "source_sha256": "f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d", + "glb": "f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d.glb", + "glb_sha256": "0b43c6087d26a19e6863a1d488eb6e3e524240793d850b27f844e4c194818afa", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436": { + "source": "Tree/ymir work/tree/n2_bananatree_rt_02.spt", + "source_sha256": "23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436", + "glb": "23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436.glb", + "glb_sha256": "c5bd49676be213f69141b644b109a890f682fcc31ae8bf4fb0d078b72b28d8b8", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354": { + "source": "Tree/ymir work/tree/n2_cinnamonfern_rt_01.spt", + "source_sha256": "93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354", + "glb": "93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354.glb", + "glb_sha256": "027fe6300b18a9608ffe465d1e1c77783a8c05c17e99c2999e058095ed62de05", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 1, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e": { + "source": "Tree/ymir work/tree/n2_cinnamonfern_rt_02.spt", + "source_sha256": "dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e", + "glb": "dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e.glb", + "glb_sha256": "0c537775ba7e98e11b6d5b74a6665dfd5eb74c94f70ad39c80c4f0979b5246ac", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 1, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462": { + "source": "Tree/ymir work/tree/n2_cinnamonfern_rt_03.spt", + "source_sha256": "82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462", + "glb": "82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462.glb", + "glb_sha256": "1d38479f01b5dac599ff8dbc80dab1657671950047da6bae43e7a201562da106", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 1, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff": { + "source": "Tree/ymir work/tree/n2_coconutpalm_rt_01.spt", + "source_sha256": "af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff", + "glb": "af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff.glb", + "glb_sha256": "3344943038999faf012f8d1520b2feaaab367c06a6b24748685a233b89f7ec70", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828": { + "source": "Tree/ymir work/tree/n2_coconutpalm_rt_02.spt", + "source_sha256": "e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828", + "glb": "e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828.glb", + "glb_sha256": "3f46282c3c8d094206d516475cf1e086e87af584c4537c3d48da1e2ee544a248", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6": { + "source": "Tree/ymir work/tree/n2_coconutpalm_rt_03.spt", + "source_sha256": "75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6", + "glb": "75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6.glb", + "glb_sha256": "1e19c9fe65ef02d0277206f4737eeafec5541ba6bc2619dcb015e31bf8d76800", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28": { + "source": "Tree/ymir work/tree/n2_curlypalm_rt_01.spt", + "source_sha256": "8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28", + "glb": "8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28.glb", + "glb_sha256": "a0859095e4b20c71d33e3cde76696584880df357d3642c07d79355cb0d732809", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e": { + "source": "Tree/ymir work/tree/n2_curlypalm_rt_02.spt", + "source_sha256": "de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e", + "glb": "de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e.glb", + "glb_sha256": "c8b1d7ad7faf78e14b0c50f7c33dd9712a2c79dbec54a574945e2b20f7e22d4a", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af": { + "source": "Tree/ymir work/tree/n2_curlypalm_rt_03.spt", + "source_sha256": "97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af", + "glb": "97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af.glb", + "glb_sha256": "700aa9caba46ed1b7141d58a653d77ea9eb96911172d1a4dacb414328c8f18ee", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e": { + "source": "Tree/ymir work/tree/n2_datepalm_rt_01.spt", + "source_sha256": "6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e", + "glb": "6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e.glb", + "glb_sha256": "7c2a38b68b0b33d46db13830c12302a1962584e51e628386d654509998e1795f", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255": { + "source": "Tree/ymir work/tree/n2_datepalm_rt_02.spt", + "source_sha256": "55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255", + "glb": "55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255.glb", + "glb_sha256": "e7391ec2d87aa7df6f87943e4faaf91de5951dccb045e6935844c997f7de1179", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d": { + "source": "Tree/ymir work/tree/n2_datepalm_rt_03.spt", + "source_sha256": "2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d", + "glb": "2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d.glb", + "glb_sha256": "ec41d7cb6c58864cc32bccca5e364d8fa4746501d4bf9a37d3f64c611834e0c9", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 2, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f": { + "source": "Tree/ymir work/tree/n2_joshuatree_rt_01.spt", + "source_sha256": "2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f", + "glb": "2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f.glb", + "glb_sha256": "27937a1b7cb30186a7041b4c8e405e531a3152004d5365082d9e0908e3998670", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 4, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5": { + "source": "Tree/ymir work/tree/n2_joshuatree_rt_02.spt", + "source_sha256": "111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5", + "glb": "111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5.glb", + "glb_sha256": "6082d34315db4cd58e606b4b35cdbd07ab374d7587580512e07b0e27cfdedb11", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 4, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5": { + "source": "Tree/ymir work/tree/n2_joshuatree_rt_03.spt", + "source_sha256": "a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5", + "glb": "a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5.glb", + "glb_sha256": "5964a02431360700dabd0f88cddde3419141f9f2626b48068b80a6da4110ddf6", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 6, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119": { + "source": "Tree/ymir work/tree/n2_palmetto_rt_01.spt", + "source_sha256": "19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119", + "glb": "19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119.glb", + "glb_sha256": "6fc27607202b42a36b0cea01228e05e3ffd36fb21800ed5ba171831c8a4b7ae3", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823": { + "source": "Tree/ymir work/tree/n2_palmetto_rt_02.spt", + "source_sha256": "e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823", + "glb": "e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823.glb", + "glb_sha256": "1597e961990b4bae697f92238b93978910c39d839f8585baaa44cda135197cde", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + }, + "d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4": { + "source": "Tree/ymir work/tree/n2_palmetto_rt_03.spt", + "source_sha256": "d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4", + "glb": "d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4.glb", + "glb_sha256": "cb67edad8c58da648f2e7b19e5cce0d1ec665bb7738f0fa91ac0fd7dab1176ec", + "native_extractor": "spt_extract.exe", + "extractor_runs": 2, + "primitives": 5, + "leaves": "static_extracted_cards", + "wind": "not_baked" + } + } +} diff --git a/build-macos-client.sh b/build-macos-client.sh index d984da6e..9543fb62 100755 --- a/build-macos-client.sh +++ b/build-macos-client.sh @@ -10,10 +10,21 @@ set -euo pipefail cd "$(dirname "$0")" REPO="$(pwd)" CFG="${1:-debug}" -OUT="$REPO/build/export" +OUT="${MT_MAC_OUTPUT_DIR:-$REPO/build/export}" APP="$OUT/mtgodot-poc.app" GODOT="${GODOT:-godot}" MAC_ARCHES="${MT_MAC_ARCHES:-universal}" +ENGINE_OVERRIDE="${MT_MAC_ENGINE:-}" +if [ -n "$ENGINE_OVERRIDE" ]; then + test -x "$ENGINE_OVERRIDE" + ENGINE_INFO="$(lipo -info "$ENGINE_OVERRIDE")" + case "$MAC_ARCHES:$ENGINE_INFO" in + universal:*x86_64*arm64*|universal:*arm64*x86_64*|arm64:*arm64*|x86_64:*x86_64*) ;; + *) echo "错误:自定义引擎架构不匹配:$ENGINE_INFO" >&2; exit 1 ;; + esac + echo "自定义引擎:$ENGINE_OVERRIDE" + "$ENGINE_OVERRIDE" --version +fi BUILD_TYPE="Debug" if [ "$CFG" = release ]; then BUILD_TYPE="Release" @@ -55,6 +66,9 @@ ditto "$REPO/bgm" "$APP/Contents/Resources/bgm" test -f "$APP/Contents/Resources/assets/asset_index.txt" echo "== 4. 签名(默认 ad-hoc;CODESIGN_IDENTITY 可切正式证书)==" +if [ -n "$ENGINE_OVERRIDE" ]; then + cp "$ENGINE_OVERRIDE" "$APP/Contents/MacOS/mtgodot-poc" +fi CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}" ENTITLEMENTS="$REPO/macos.entitlements" test -f "$ENTITLEMENTS" diff --git a/docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md b/docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md new file mode 100644 index 00000000..277cd72b --- /dev/null +++ b/docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md @@ -0,0 +1,275 @@ +# 首个 Mac 联网内测版:开发实施文档 + +日期:2026-09-11。范围:Apple Silicon / arm64 Release。 + +本文将联网闭环、基础战斗、首测地图和稳定性四项需求拆为可开发任务。本文新增的文件、API、环境变量均为**待实现设计**,不是已存在功能;列为“现有”的入口已按当前工作树核对。本轮只编写文档,不执行联网测试或修改运行时代码。 + +## 1. 基线与交付边界 + +现有候选包:`build/export-native-trees-20260911/mtgodot-poc.app`。已通过离线包内加载及干净退出;树木专项覆盖 87 种资源、六图 2092 棵树,不能代替联网或树怪验收。 + +首版放行不是“渲染全部完成”。必须交付:固定哈希的候选包、实际联网闭环证据、基础战斗矩阵、首测地图截图、稳定性报告和已知问题。风动、逐叶朝向、SpeedTree 原生 LOD、40250 全环境像素一致性可延期。 + +首测地图拟为出生城镇及练级区、鬼木林、赤鬼木林。开发前通过资源和实际服务器确定 map key、进入坐标、合法路线、角色槽位与怪物刷新;不得凭中文名硬编码地图路径。测试角色等级、装备、技能、掉落条件需由测试环境负责人准备;没有条件时报告 BLOCKED,禁止伪造事件或偷偷修改服务器。 + +## 2. 已有实现与明确缺口 + +| 现有文件 / API | 可复用部分 | 本轮必须补齐 | +| --- | --- | --- | +| `script/live_smoke_test.sh` | 指定 MT_SMOKE_APP、凭据输入、报告与进程码 | 地址预检写死;无完整退出错误门禁;缺墙钟超时和战斗流程 | +| `project/live_smoke_test.gd` | setup、状态机、世界读取、移动、聊天、reconnect | 发包成功不是服务端结果;无完整打怪拾取链 | +| `project/client_main.gd` | 包内 MT_TEST_MODE=smoke/render 分流 | 增加 playable、地图视觉入口;普通启动不启用测试 | +| `project/app_flow.gd` | start、state、登录/选角/进场和断线生命周期 | 提供只读场景快照,避免测试遍历私有节点 | +| `project/net_play.gd` | set_attack_key、pick_ground_item、skill_context | 提取公共选目标入口,沿用真实战斗逻辑 | +| `project/ui/quickbar.gd` | activate、assign、restore_from_server;skill_cast_started、skill_rejected | 输入、服务端结果与视觉证据关联 | +| `project/player_skill.gd` | use_skill、可用性与目标检查 | 新增负向/生命周期覆盖,不另造施法实现 | +| `project/ui/mob_view.gd` | build、set_anim_state、motlist 映射 | 树怪矩阵及禁止静默动作回退的验收 | +| `project/rendering_scenario_test.gd` | 离线 CPU/GPU 多角色、帧时间、内存计数 | P99、事件关联、RSS、长期联网负载;现有假客户端不是实网证据 | +| `script/package_render_test.sh` | 进程退出后扫描完整日志 | 抽取通用门禁供联网/稳定性使用 | + +当前 `mob_winding_test.gd` 只覆盖 101、110、20001,不覆盖树怪。`native_tree_test.gd` 测的是静态场景树,不能复用其 PASS 证明怪物蒙皮正确。 + +## 3. 开发拆分与依赖 + +每个任务需单独可审查 diff,先添加失败用例再修复;工作树已有修改必须保留,不把无关修改混进任务结论。 + +| ID | 开发内容 | 依赖 | 完成产物 | +| --- | --- | --- | --- | +| INF-01 | 场景清单、报告协议、事件采集、配置校验 | 无 | 配置负测及报告协议测试 | +| INF-02 | 通用包运行器、墙钟超时、退出门禁 | INF-01 | 假进程门禁测试 | +| NET-01 | 包内真实联网状态机、只读诊断接口 | INF-01/02 | 3 次真实闭环 | +| CBT-01 | 技能/普攻输入路径与证据关联 | NET-01 | 四职业矩阵 | +| MAP-01 | 地图/树怪资源审计与离线 Metal 截图 | INF-01,可与 NET 并行 | 资源清单、动作截图 | +| MAP-02 | 实际地图定位、战斗、落地检查 | NET-01、MAP-01 | 首测地图验收 | +| STB-01 | 采样、循环场景、故障与窗口测试 | NET-01、CBT-01、MAP-02 | 2 小时报告 | +| REL-01 | 重打包、聚合门禁、已知问题 | 全部 | 可追溯内测包 | + +实际运行缺陷的修复范围由证据决定;不能预先将未知 bug 标成已定位。缺陷修复卡必须写出:失败用例 ID、资源/角色/地图、原因、修改文件、修复前后证据、回归集合。 + +## 4. INF-01:测试基础设施的数据契约 + +### 4.1 新增文件 + +- `test/playable/scenario.example.json`:无账号密码的模板,未解析的地图/技能字段留空并拒绝运行。 +- `project/testing/playable_config.gd`:验证配置、场景与资源前置条件。 +- `project/testing/playable_report.gd`:统一检查、事件记录与最终报告。 +- `project/testing/playable_probe.gd`:只读订阅生产对象,不发包、不写实体数据。 +- `project/playable_harness_test.gd`:假客户端验证状态机和证据判定,不作为实网验收。 + +配置采用 schema_version=1;包含 scenario_id、protocol、serverinfo 中选定地址、character_slot、map_key、waypoints_cm、allowed_mob_vnums、allowed_drop_vnums、skill_cases、resolution、loops、timeout_seconds。所有坐标字段显式带单位,网络到世界换算沿用生产代码;不把 Vector2/Vector3 强转用于凑通过。路线必须预先人工确认安全可达。 + +账号密码仅通过现有 MT_ACCOUNT/MT_PASSWORD 或安全交互输入提供;禁止写入 JSON、命令行参数、截图或报告。报告不复制进程环境。启动脚本不能使用 set -x。日志在落盘前脱敏,测试账号名也不写入最终公开报告。 + +### 4.2 报告契约 + +`report.json` 必须具有如下字段: + +```json +{ + "schema_version": 1, + "run_id": "unique-run-id", + "suite": "playable", + "status": "BLOCKED", + "build": {"engine_sha256": "...", "extension_sha256": "...", "pck_sha256": "...", "arch": "arm64"}, + "environment": {"os": "...", "renderer": "...", "resolution": [1280, 720]}, + "cases": [{"id": "NET-ATTACK-01", "status": "BLOCKED", "duration_ms": 0, "reason": "fixture_not_ready", "evidence": []}], + "failures": [], + "blocked": ["fixture_not_ready"], + "coverage": {"required": 1, "passed": 0}, + "exit_gate": {"checked": false, "process_code": null, "errors": []} +} +``` + +状态限定 PASS/FAIL/BLOCKED/SKIP;必测项 BLOCKED、SKIP 均不得放行。未知 schema、空 cases、缺哈希、证据文件不存在、required 与清单不一致均失败。客户端只能写 `client-report.json`,父运行器在真实退出后生成最终 `report.json`,防止退出前 PASS 掩盖泄漏。 + +事件写 `events.jsonl`,每条含 monotonic_us、run_id、case_id、connection_epoch、stage、kind、actor_vid、target_vid、payload(白名单字段)。重连后 VID 可变化,按角色身份与新 epoch 重新绑定,禁止等待旧 VID。检测“来自服务端”的依据必须是接收端分发,而非本地预测信号。 + +### 4.3 必须先有的负向测试 + +无凭据/坏配置、空覆盖、旧报告、错 run_id、报告 PASS 但进程失败、PASS 后输出 RID 警告、只有本地动画无服务端事件、重连后旧事件、超时不退出,均应返回非零。任何缺测都不能降级成 PASS。 + +## 5. INF-02:统一运行与退出门禁 + +新增 `script/run_client_gate.sh` 和 `script/validate_playable_report.mjs`,由 `script/playable_test.sh` 调用;复用 `package_render_test.sh` 的错误匹配规则,不弱化现有门禁。 + +1. 每次创建唯一输出目录,记录本次 run_id 和包哈希;不复用历史报告。 +2. 校验包可执行、架构和签名;清除 MT_ASSETS,保证验收用包内资源。 +3. 预检地址与 `project/net/serverinfo.gd` 的实际选中配置一致。新增测试配置覆盖只能在测试模式生效;删除冒烟脚本预检地址与客户端连接地址分叉的风险。 +4. 只管理本运行器启动的子进程 PID;超时先 TERM,宽限 10 秒后 KILL,再 wait 收集状态。不得 pkill 所有客户端或修改全机网络。 +5. 读取完整标准输出/错误,检查 SCRIPT ERROR、ERROR、leaked at exit、shaders never freed。无过滤 RID 警告的允许名单。 +6. 同时满足进程 0、报告协议正确、全部必测 PASS、完整日志门禁通过,才写最终 PASS。 + +建议退出码:0=PASS,1=断言或退出门禁失败,2=配置/前置条件阻塞,124=墙钟超时。进程原始退出码另存报告,不丢失。 + +门禁单测使用脚本生成的假子进程输出,不需游戏服务器。确认超时分支、信号退出和 stdout 管道不会绕过真实进程码。 + +## 6. NET-01:真实联网闭环状态机 + +### 6.1 修改点与接口 + +- 新增 `project/playable_live_test.gd`,实现 `setup(flow: Node, client: Node, config: Dictionary)`。 +- `client_main.gd` 增加 `MT_TEST_MODE=playable` 分支,在 AppFlow 建立后挂载该 Node;不得导出后用 --script 替代包内入口。 +- `app_flow.gd` 新增只读 `get_playable_snapshot() -> Dictionary`,返回 stage、角色身份摘要、scene_ready 和当前地图。测试对象引用通过明确的测试 adapter 获取,不把 Node 放入 JSON。 +- `game_scene.gd` 新增只读 `get_playable_context() -> Dictionary`,在测试模式提供现有 net_play、quickbar、net_world、world、player 的引用;场景销毁时失效。 +- `net_play.gd` 新增公共 `select_target(vid: int) -> bool`,复用现有 `_change_target_to_picked_instance` 与 `_on_pick` 的共同目标选择逻辑;保留距离、死亡和可攻击检查,不直接改私有字段。 + +先核实 M2Client 信号签名与 classic 收包源;缺少来源可辨识证据时,向现有接收分发处增加测试诊断通知,不添加假业务事件,不改变协议格式。 + +### 6.2 状态及判定 + +| 状态 | 动作 | 成功证据 | 默认超时 | +| --- | --- | --- | --- | +| LOGIN | 复用 AppFlow 自动登录 | char_list 接收且指定槽位存在 | 30s | +| SELECT | 现有选角流程 | entered_game + 主实体有效 | 45s | +| WORLD_READY | 等待场景加载 | map key 符合、地图与角色就绪、HUD 可用 | 60s | +| MOVE | 通过玩家控制器走配置路线 | 本地到达 + 无服务端纠正;重登/服务端观察验证落点 | 20s/点 | +| TARGET | select_target 选择允许范围怪物 | 当前 epoch 中存活实体、race 合法 | 30s | +| ATTACK | set_attack_key(true),结束/失败必释放 | 指定目标接收血量/伤害变化与死亡结果 | 90s | +| DROP | 观察真实掉落 | 新地面物品、允许 vnum、可拾取归属和邻近位置 | 30s | +| PICKUP | pick_ground_item(iid) | 地面消失 + 背包对应 vnum 总量增加 | 15s | +| EXIT | 正常退出入口 | 父运行器检查真实退出和完整日志 | 20s | + +MOVE 不要求服务器必然回显主角每一个移动包;classic 若无回显,使用重登位置或服务器/第二观察客户端确认,不把本地 position 变化叫做服务端确认。若没有可用证据,标记该断言 BLOCKED。 + +拾取比较所有背包格的 vnum 总量(支持堆叠),并关联时间窗和地面 iid;只看到地面删除不能证明由自己拾取。金钱掉落另测点数,不套背包断言。无掉落的随机战斗不能无限重试:测试环境准备确定可拾取掉落,最多 3 次允许怪物击杀,无条件则 BLOCKED。 + +每个状态入口记录快照,失败保存截图;禁止跨状态复用旧计数。断线立即取消输入、释放目标和待处理操作。统一 teardown 断开监听,场景 free 后 probe 不再访问节点。 + +### 6.3 用例与完成条件 + +- NET-01:完整闭环 3 次,每次独立进程与报告。 +- NET-02:无效账号和不存在槽位,明确错误且有界退出(限制尝试,避免账号锁定)。 +- NET-03:进场期间断线,不能留下半加载操作对象。 +- NET-04:目标在攻击前消失,取消操作而非继续攻击旧 VID。 +- NET-05:掉落被他人拾取、背包已满,不能误报成功;需专用夹具或离线负测,实网缺条件须标出。 + +自动调用业务入口不等于鼠标命中正确;另做一次真实鼠标/键盘闭环,记录输入设备与截图,作为手动必测项。 + +## 7. CBT-01:基础战斗和技能 + +新增 `project/playable_combat_test.gd`(离线事件与输入断言)、`test/playable/skill-cases.json`(实际角色技能矩阵)。联网用例由 playable_live_test 调度,不再创建第二条发包路径。 + +### 7.1 实现与定位 + +1. 通过 Quickbar.assign/restore_from_server 构造测试账号实际槽位;需要持久化变更时仅对专用账号执行,记录原值并在可行时恢复。无服务端技能等级不得本地伪造。 +2. 调用 activate 复用 PlayerSkill.use_skill,监听现有 skill_cast_started(skill_id,target_vid) 与 skill_rejected(skill_id,code)。cast_started 只是本地施法开始,不是服务端命中证明。 +3. case_id 绑定施法时目标;施法中换目标后,定点效果仍使用原目标快照,跟随效果按技能定义更新。 +4. probe 关联接收端资源/状态变化、目标血量或伤害事件;不同技能配置 required_evidence,不强制所有技能都有伤害或资源消耗。 +5. 在 `game_scene.gd`、`fx/skill_fx.gd`、`fx/effect_player.gd` 的实际生成/结束边界记录测试事件。不可只用节点数量增加判定特效正确。 +6. 自动截图覆盖施法前、触发帧、峰值、结束;短命特效按事件时间采样,不统一等一秒。 + +诊断路由:图标/点击区域→quickbar;拒绝条件/目标→player_skill;动作触发→net_play 与玩家视图;挂点/生命周期→game_scene、skill_fx、effect_player;骨骼坐标→metin2_model/metin2_anim。只在复现后修改对应层。 + +### 7.2 测试矩阵 + +每职业从真实已学技能中选择适用类型:单体、范围、自身增益、飞行效果;不存在的类型标注不适用并经清单确认,不擅自补技能 ID。每技能成功施放 10 次;另覆盖无目标、超距、冷却、资源不足、目标死亡、自身死亡、切图/断线、目标切换。 + +断言包括:拒绝不发有效施法请求;正常请求数无重复;服务端效果可观察;角色持续可见且包围盒有限;特效在定义寿命+1秒宽限内清理;挂点相对预期骨骼/目标坐标误差默认不超过 0.1m(有资源偏移需记录校准值)。 + +在 `skill_test.gd`、`player_skill_test.gd`、`combat_fx_test.gd`、`target_effect_test.gd` 扩展相应失败用例,并纳入 rendering_batch_test。截图必须人工复核一次,像素非空不能证明技能外观正确。 + +完成条件:必测职业/技能覆盖完整、功能阻断清零、首次施法与重复施法均通过;高级特效差异写入已知问题,不改成假 PASS。 + +## 8. MAP-01/02:鬼木林、赤鬼木林与树怪 + +### 8.1 资源审计 + +新增 `script/audit_playable_maps.mjs`,输出 `map-assets.json`。读取实际 map 配置、AreaData/Property、npclist、MSM、motlist 和引用文件,分别列出静态 SPT、动态 GR2、贴图、动作及缺失项。没有服务端刷新数据时只报告候选树怪,不声称已确定地图所有怪物。 + +已核实 `assets/root/npclist.txt`:2301–2307 对应 ent_trent/ent_guru/ent_hu/ent_red/ent_black/ent_huge/ent_elder;2311–2315 对应前五种。共享模型仍保留独立 race 用例,避免漏掉 proto/外观配置差异。实际 map key 与刷新关联由前置审计填入配置。 + +### 8.2 单模型 GPU 验收 + +新增 `project/forest_mob_render_test.gd`,沿用 MobView.build、set_anim_state 和现有 Metin2Model/Metin2AnimPlayer。读取 motlist 的具体文件,报告 requested_state、resolved_motion、fallback_reason;“调用 dead 却播放 wait”不能通过。 + +对 12 个候选 race × CPU/GPU × wait/run/attack/damage/dead 生成基准集合;资源确无动作时据旧客户端回退规则单独判定,不能静默跳过。动作每个采样起点、25%、50%、75%、末段;固定随机种子或明确选择变体。死亡动作不能为了截图强行循环。 + +自动断言:构建成功;贴图实际加载;顶点/包围盒有限;无非法材质;同一姿态 CPU/GPU 包围盒一致(初始容差 1cm 或高度0.5%,取较大值);截图非空。容差变更需记录理由,不通过放宽到无限解决失败。 + +输出每 race 的正面、侧面、动作 contact sheet 和 JSON。检查材质双面/裁剪应依资源,不对所有树怪强制双面;蒙皮异常先检查骨骼索引、权重、bind pose、坐标,再修改 C++ 公共路径。`mob_winding_test.gd` 增加这些 race,若影响全怪物必须回归原 101/110/20001。 + +### 8.3 地图内验收 + +新增 `project/forest_map_render_test.gd` 和包内 `MT_TEST_MODE=forest_render` 分支,加载配置指定的生产地图。新测试模式不存在于旧包,必须重新构建后执行。 + +在经确认的平地、坡地、密林和传送点保存固定机位截图,记录 world.sample_height、怪物世界位置、ground offset、材质和实际动作路径。平地不允许持续悬空/埋地超过校准阈值;坡地需人工检查脚/根部接触,AABB 底部不是精确足部,不能用它宣布足部 IK 完成。 + +实网追加逐地图进入、选中树怪、攻击、死亡、拾取、离开。静态地图截图与动态服务器刷新分别记录证据,不混为一次测试。 + +完成条件:首测区域关键资源无未解释缺失、主要动作和目标交互正常、Metal 截图人工签核;其余地图不在本轮覆盖中。 + +## 9. STB-01:稳定性与长帧 + +新增 `project/testing/playable_metrics.gd` 和 `script/playable_soak.sh`,扩展 rendering_scenario_test 的 P99 与事件时间点,但保留“离线合成”和“真实联网”两种 suite 标签。 + +### 9.1 指标实现 + +- 每帧记录 monotonic 间隔;按 map/phase/actor_count/gpu_skin/动作切换标记。加载与游玩分段统计。 +- 固定大小环形缓冲,按时间窗口输出分位数和 >50ms/>100ms 样本;不得无限数组增长制造测试自身内存泄漏。 +- 默认关闭逐帧打印,只保留长帧事件;采样开启/关闭各跑短基线,量化探针开销。 +- 外部采样父运行器所持 PID 的 RSS,每秒一次,记录单位 KiB;Godot MEMORY_STATIC 单独记录。GPU 内存拿不到写 null/unavailable,不填写 0 或据此宣称无显存泄漏。 +- 缓存预热后比较重复路线每轮结束并静置30秒的低水位;记录前5轮与后5轮中位数。连续5轮增长且后5轮中位数高出 max(50MiB,5%) 时门禁失败并分析;阈值以下也不能证明长期无泄漏。 + +### 9.2 执行场景与边界 + +2小时实网循环移动/战斗/拾取;20次经合法传送入口切图;10次重连;3种受支持逻辑分辨率各切10次;10次正常退出。窗口物理像素与逻辑尺寸都记入报告,不能把 Retina 缩放当成分辨率错误。 + +主动 reconnect() 与真实传输故障分别计数。真实故障至少覆盖“连接被关闭”和“短时不可达”;仅测试专用连接,可使用经确认的局部代理/测试网络条件,不改全机路由、防火墙、不关生产服务。缺少该条件标为 BLOCKED,不用主动重连替代断网验收。 + +时间采用墙钟期限,不用固定帧数冒充2小时。重连后重新获取主 VID、场景和 UI 对象,检测重复信号、重复实体、旧目标/技能残留。跨服切图需核实测试服务器支持,失败不能通过直接传送客户端节点掩盖。 + +### 9.3 长帧处理流程 + +先标记同步模型 build、动作 reload、GR2解析、材质初次使用和实体批量生成;记录缓存命中。确认瓶颈后才做路径级修改:动画缓存→metin2_anim;实例建立→net_world/game_scene;材质预热→实际渲染建立路径。后台工作只做线程安全的解析,不把 SceneTree/RenderingServer 操作随意搬入工作线程。 + +相同机器/路线对比修改前后3轮,保留最大帧而非只报平均。正常游玩目标60FPS;同一交互3次重复中至少2次出现 >100ms 视为阻断,任何 >50ms 样本需归因。首次加载独立预算在基线采集后固定,不临时扩大预算让门禁通过。 + +完成条件:零崩溃/永久卡死、循环后功能正常、无达到上述阈值的未解释内存增长、无重复严重长帧、每次退出干净。冷启动报告注明进程冷启动/系统缓存状态未知,不执行系统缓存清空来影响用户其他程序。 + +## 10. 执行命令与验证顺序 + +以下命令在仓库根目录执行。账号通过安全交互提供,禁止放入命令历史。新脚本落地前不得宣称这些未来命令已可运行。 + +现有可执行回归: + +```bash +bash script/rendering_batch_test.sh +MT_RENDER_APP="$PWD/build/export-native-trees-20260911/mtgodot-poc.app" bash script/package_render_test.sh +``` + +待实现 CLI 契约(各脚本必须有 --help、配置校验、非零失败码): + +```bash +# 离线资源/蒙皮/报告门禁,不连接服务器 +node script/audit_playable_maps.mjs --config test/playable/scenario.local.json --output build/playable/map-assets.json +godot --headless --path project --script playable_harness_test.gd +godot --path project --script forest_mob_render_test.gd + +# 重新构建带新包内测试入口的候选包,不能用旧包冒充 +MT_MAC_ARCHES=arm64 MT_MAC_ENGINE="$PWD/build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64" MT_MAC_OUTPUT_DIR="$PWD/build/export-playable-rc1" bash build-macos-client.sh release + +# 明确显式允许专用账号上的游戏操作;默认仅校验配置,不发移动/战斗请求 +bash script/playable_test.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --suite full --allow-gameplay --repeat 3 +bash script/playable_soak.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --allow-gameplay --duration-seconds 7200 +node script/validate_playable_report.mjs --release-dir build/playable/rc1 +``` + +新脚本参数还需支持 --output;聚合器只接受显式列出的本次 run_id 与相同包哈希,不扫描历史 PASS 凑覆盖。测试模式、测试账号均禁止自动聊天、交易、出售、丢弃物品或创建/删除角色;不沿用旧 smoke 的聊天副作用。 + +配置 local.json、凭据文件、运行产物加入适当忽略规则;提交无敏感信息的 example/schema。最终修复 C++ 后必须重建同步扩展、重新签名和打包;脚本变更也必须重新导出 PCK。 + +## 11. REL-01:放行检查表 + +- [ ] INF-01/02 负向用例通过,必测项缺失不能 PASS。 +- [ ] 同一候选包3轮联网闭环通过,另有鼠标键盘流程证据。 +- [ ] 四职业基础战斗、技能及异常路径清单完整。 +- [ ] 首测地图和树怪资源/动作/Metal画面验收通过。 +- [ ] 2小时、切图、故障恢复、窗口缩放、退出门禁达到配置次数。 +- [ ] P0(崩溃/数据异常/无法进场)与 P1(核心操作失效/主要模型丢失/严重错位/重复严重卡顿)清零。 +- [ ] 所有 BLOCKED 明确处理;首测必测项不得以“已知问题”绕过。 +- [ ] 包哈希、签名、引擎来源、设备信息与报告一致。 +- [ ] 已知 P2、未覆盖地图/装备/技能及未验收平台明确列出。 + +官方 Release 模板仍有粒子退出警告;必须显式使用验证过的自编译 arm64 引擎,并对最终包重新跑退出门禁。不将当前方案表述为已修复所有官方模板或 Intel 平台。 + +完成文档不等于完成上述开发。开发起点是 INF-01/02;它们完成后 NET-01 与 MAP-01 可并行推进。工期在首次真实闭环和树怪截图产生后根据实际缺陷估算。 diff --git a/docs/RENDERING-REPAIR-PLAN.md b/docs/RENDERING-REPAIR-PLAN.md index 0adcd742..f83dc1f0 100644 --- a/docs/RENDERING-REPAIR-PLAN.md +++ b/docs/RENDERING-REPAIR-PLAN.md @@ -1,6 +1,8 @@ # 新客户端渲染修复实施路线 -更新日期:2026-09-08。目标客户端:本仓库 `metin2-client`,第一交付平台为 macOS。 +更新日期:2026-09-09。目标客户端:本仓库 `metin2-client`,第一交付平台为 macOS。 + +最新集中交付见文末「集中修复、整套回归与 mac 实包验收」:已生成新 universal mac 包,独立资源加载与只读真实登录通过;剩余视觉保真和性能项未宣称完成。前文表格及旧执行记录保留历史状态,以最新记录为准。 本文是实施规格,2026-09-08 已开始第一批 R0/R1 修改;阶段结果见文末执行记录。未标明通过的工作仍待执行,不代表整体渲染或最终包完成。 @@ -369,3 +371,300 @@ godot --path project --script model_render_test.gd # PASS( 不在这次修复范围内。 - R2-01 的 GLB 对照、R0 完整视觉基线与区域阈值、R4 选角/快捷栏、R5 最终包验收、R6 性能,均未开始。 + +### 2026-09-08 后续复核:种族与动画资源配套 + +- 修复 `PlayerView.build()` 用性别选择 pc/pc2 的错误。资源中的 `playersettingmodule.py` 明确规定 race 0..3 使用 pc、4..7 使用 pc2;女性刺客/萨满属于 pc。原实现使 race 1/3/5/7 初始模型性别错误,EquipModel 从 MSM 恢复正确身体后仍保留另一性别动作。现在按 race 范围选择同一资源族;对应资源缺失时返回构建失败,避免跨性别回退。 +- 新增 `race_motion_assembly_test.gd`:以真实 MSM 换装后的身体目录为独立依据,检查身体、发型与动作资源配套,覆盖八种族 × 六动作(wait/walk/run/attack/damage/dead)× 三个时间点。重新构建实际加载的扩展后运行结果 `failures=0`;`race_spec_test.gd` 通过。有限包围盒断言不等于完整动作画面或骨骼精度验收。 +- 通过 `bash script/rendering_test.sh` 重建并同步 debug 扩展;CPU 绕序与选角装配通过。旧本地库复现已修复的约一米悬空,重建后 `model_render_test.gd` 的八种族足底、剪影下限和材质负对照绝对断言通过。 +- **像素回归仍失败,未更新基线**:`model_render_test.gd` 的 head_cover、silhouette_px、hair_mean_lum 与保存基线不一致。种族 1/3/5/7 原基线含错误性别资源;其余种族亦存在亮度或剪影差异,须继续排查合并后材质/绕序和基线生成条件。当前扩展 SHA-256:`b682582b2c96d7b9bd005d75ac781dde8d35fbe309ca01792d5ae3c5f35c5409`。不得沿用旧“八职业 PASS”作为当前代码像素验收结论。 +- 剩余:像素基线差异定位、装备/发型组合与完整动作视觉验收、GLB 对照、坡地/台阶、R3 世界与光照、R4 UI 联机恢复、R5 最终包、R6 性能。上述新修复尚未在最终导出包或真实登录中复验。 + +### 2026-09-08 像素基线差异闭环 + +- 确认基线来自合并前 `e0c18539`:其 CPU 蒙皮没有应用 flip_winding。诊断参数 `--legacy-winding` 恢复旧索引顺序后,race 0 对旧基线 PASS,hair_mean_lum=0.060864(基线 0.060796),head_cover=0.324646(基线 0.324674)。这解释了合并后双面头发的光照/遮挡变化;race 1/3/5/7 还受已修正的性别资源映射影响。 +- 人工检查八种族正面和侧面近景:模型性别、脸和发型正确,无先前浅色三角斑。保留当前正确绕序与资源映射,用生产路径重建 `model_baseline.json`,未放宽任何像素阈值。近景为固定局部采样,部分头顶出框,不能据此宣称完整头部多角度验收。 +- `model_render_test.gd` 冻结采样时关闭动作过渡,报告/基线增加身体和发型 SHA-256;回归检查资源哈希。仅在全部绝对断言通过、八种族生产模式运行时允许写基线,禁止单种族或旧绕序诊断覆盖它。 +- 重建报告:`build/rendering/modeltest-1788861134/report.json`。三次独立复测:`modeltest-1788861136`、`modeltest-1788861139`、`modeltest-1788861142`,均 PASS。旧绕序对新基线负对照失败,证明测试仍可检测旧行为。以上仅为当前 CPU 基础模型单角色场景;GPU、换装和最终包仍待独立验收。 + +### 2026-09-08 GPU 发型切换与选角朝向 + +- 修复 `_load_hair()` 后 GPU 合并网格未刷新的问题:换发型/颜色与移除发型现在重建网格、重新分配身体/头发材质,并更新当前姿势包围盒。CPU 移除发型也即时刷新,不依赖下一动画帧。GPU 发型采用与 CPU 一致的双面材质。 +- `character_winding_test.gd` 增加暂停动画期间移除/替换头发、纹理像素比对、双面材质与身体不误分类检查。CPU/GPU 均 failures=0。重新构建扩展后八种族 CPU 像素回归 PASS,基线未改。 +- 实景发现 InitialPlacement 修正后,选角仍保留旧 90 度补偿,导致人物侧向镜头;现移除该补偿。男战士 5th 护甲 + HairIndex 1001 GPU 截图 `build/rendering/selection-1788861936-4253/selection.png` 人工复核正面与完整入框;CPU 装配截图报告 `selection-1788861960-4277` PASS。 +- 本轮不代表完整 GPU 动作/LOD、多职业高级装备或最终导出包验收通过;这些仍需继续覆盖。 + +### 2026-09-08 GPU LOD 附件丢失修复 + +- 确认 `_set_lod()` 在 GPU 路径仅调用身体 `build_mesh()`,切远景时丢掉合并头发,回到 LOD0 仍不恢复,且保留旧身体/发型材质分界。现在 GPU LOD 使用 `_build_gpu_mesh()`,重建身体与头发后刷新材质和当前姿势包围盒。 +- 新增 `gpu_lod_attachment_test.gd`,使用实际相机距离驱动八种族 LOD 0→1→2→3→2→1→0。检查实际 LOD、头发表面/顶点、alpha-test 和骨矩阵纹理。修改前 144 项失败;修改后 headless 与 Metal 实际渲染后端均 failures=0。这是网格/材质状态验收,不是动画全周期像素或剔除边界验收。 +- `bash script/rendering_test.sh` 构建成功;GPU `character_winding_test.gd` 通过;八种族 CPU `model_render_test.gd` PASS,未调整基线。实际加载库 SHA-256:`306265206b3a5cf5c8066d908613a1df3cf17074c2bc44ebee71ac2f7cd450bd`。 +- 仍需:GPU 完整动作的动态剔除包围盒、LOD 淡入淡出视觉检查、多职业高级装备、坡地与最终包复验。 + +### 2026-09-08 GPU 动态剔除范围修复 + +- 原 GPU 路径只在首帧/LOD/换发型时更新剔除盒;后续跑步、攻击、死亡沿用旧姿势范围。现在每次 `gpu_skin()` 采样同步当前身体和发型包围盒。首次就绪信号仍只触发一次,避免选角镜头随待机呼吸不断重新取景;取景查询直接读取已更新的结果。 +- 新增 `gpu_pose_bounds_test.gd`,八种族 × 六动作 × 五个时间点(含非循环末帧),将 GPU 实际 `custom_aabb` 与同姿势 CPU 网格包围盒比较,不调用会刷新范围的取景接口。修改前 195/240 采样失败,修改后 headless 和 Metal 均 failures=0;GPU LOD 附件回归亦通过。 +- 构建入口 `bash script/rendering_test.sh` 通过;库 SHA-256:`8e7d635dbb9b407d8b41b61c9e73330e12ecece6033ab1b3a8885f796e24a32d`。包围盒计算增加逐采样 CPU 顶点遍历成本,尚未测量多角色帧时间,GPU 仍属实验模式;后续可在保留此正确性对照的前提下优化。 +- 覆盖限于基础人物和默认发型的上述离散采样;骨骼变形像素精度、混合动作、LOD 过渡残影、坡地、高级装备与最终包仍待验收。 + +### 2026-09-08 LOD 淡出残影快照修复 + +- `_set_lod()` 的 GPU 淡出模型此前与主模型共享骨矩阵纹理,不能保持切换时姿势,且未复制动态剔除盒。现保存独立纹理快照与旧姿势 custom AABB,后续动画采样不再改变淡出模型。 +- 旧模型材质改为逐表面复制实际 active material,避免 CPU 头发单独设置的材质不在 surf_mats 缓存中而丢失。 +- 扩展 `gpu_lod_attachment_test.gd`:验证纹理隔离、后续采样不修改快照、剔除盒及逐表面材质。在旧库上 48 次淡出检查共 96 项失败;重建后 headless 与 Metal 均 failures=0、ghosts=48。构建、CPU 绕序/发型切换与选角装配亦通过。 +- 尚未完成逐帧淡入淡出视觉差异验收;CPU 淡出材质修复尚缺专门快照回归。坡地、高级装备与最终包保持待验。 + +### 2026-09-08 技能特效透明混合与静止粒子生命周期 + +- 对照 40250 `ClientVS22/extern/include/d3d8types.h`:D3DBLEND_SRCALPHA=5、INVSRCALPHA=6(4 实为 INVSRCCOLOR)。`EffectPlayer._blend()` 原先将 5/6 回落为 ADD,现映射 MIX,供粒子与网格特效共用。真实萨满 `7budongbakbu_throw.mse` 使用 5/6;此修复避免普通透明特效被误当作叠加发光。其他混合对仍保留原有近似,不代表全部 D3D 混合已还原。 +- 去除 `_advance_particle()` 在位置轨道为空时的提前返回:静止粒子同样累计时钟、遵守 StartTime、非循环与有限循环结束条件;无位置轨道不移动节点,无限循环保持原行为。 +- 新增 `effect_render_regression_test.gd`:修前 5 项失败,修后 failures=0;覆盖材质构建、无位置轨道的延迟/有限循环/非循环/无限循环,以及真实萨满 MSE 解析后的透明材质。headless 和 Metal 均通过状态断言;本测试不采集画面,不等于技能像素验收。真实资源加载测试退出时有 1 个 ObjectDB 泄漏警告,来源待定位,不能宣称资源释放验收通过。 +- `skill_fx_test.gd`、`player_skill_test.gd`、`damage_effect_test.gd` 全部 PASS;`git diff --check` 通过。本轮仅修改 GDScript,未重建扩展、导出 mac 包或联机施法验收。 + +#### 当前剩余修复 / 验收清单(历史记录中的“未开始”以本节为准) + +- 技能:停止/重播时序、RGBA 寿命曲线与重复颜色乘法已修复并补回归(见后续记录);其余混合因子、粒子发射/速度/尺寸近似、贴图动画、骨骼挂点与动作事件同步,尚需对照 40250。缺四职业多等级技能的实机逐帧视觉验收。 +- R0:40250 同资源、同机位、同动作的配对截图及区域差异验收;当前本地 CPU 回归基线不是 40250 等价证明。 +- R1/R2:切角色/重连/切图边界、高级装备与武器/发型组合、混合动作、坡地/台阶、NPC/怪物、GPU 像素精度和 GLB 同资产对照。LOD 残影已有快照修复,但仍缺逐帧视觉与 CPU 专项回归。 +- R3:角色偏暗、环境光/雾/阴影、地形混合与接缝、建筑和树木材质。 +- R4:完整选角界面对齐,技能栏实际施法/冷却/拖放/翻页/重连恢复与多分辨率验收;不应将已修复的快捷栏遮挡记为完全未做。 +- R5:包含这些新修改的最终 mac 客户端打包、独立启动与真实登录验收。 +- R6:多角色性能、缓存和资源释放;GPU 每姿势包围盒的 CPU 遍历成本尚未量化,保持实验开关。 + +### 2026-09-08 技能停止与重播时序修复 + +- 原延迟 SceneTreeTimer 在 `stop()` 后仍启动粒子,原一次性回收计时器会删除后续重播实例;原 `_process()` 还会把 stop 隐藏的灯光重新显示。改为播放进度统一驱动粒子启动和一次性回收,移除独立定时器;停止后不再推进 CPU 状态。 +- 构建粒子时关闭默认发射,`play()` 即时遵守 StartTime。重播重置粒子启动标记、位置与源循环模式,并重置网格/灯光时钟和网格帧/贴图帧缓存,重新采样零时刻。此前 `play(true)` 的 one_shot 标志不再污染后续 `play(false)`。 +- `stop()` 关闭发射并隐藏网格/灯光,已生成粒子允许自然结束;取消自动回收,实例由拥有者释放或再次播放。这不是暂停/恢复接口。正常一次性播放仍按播放时钟回收;场景暂停或禁用处理不会消耗 CPU 播放时长。 +- 新增 `effect_playback_test.gd`:修前 13 项失败,修后 headless 与 Metal 均 failures=0。覆盖停止后延迟回调、旧回收与新重播隔离、网格首帧与灯光复位、循环模式恢复、当前一次性回收,以及实际 SceneTree 帧驱动下延迟启动/停止稳定性。网格使用合成双帧夹具;不代表真实 MDE/IFL 逐帧画面验收。 +- `fx_test.gd` 灯光测试补显式 `play()`(与生产调用顺序一致)。`fx_test`、`combat_fx_test`、`skill_fx_test`、`player_skill_test`、`damage_effect_test` 及上一轮 `effect_render_regression_test` 均通过。现有真实资源/战斗测试仍分别出现 ObjectDB 退出泄漏警告,留待 R6 定位;新播放测试无此警告。`git diff --check` 通过。 +- 本轮未修改 C++、未提交、未打包或联机施法验收。上节 R0–R6 剩余项保持待办,下一步优先检查技能颜色曲线、混合与挂点同步,并建立四职业真实施法画面对照。 + +### 2026-09-08 技能 RGBA 寿命曲线修复 + +- 对照 40250 `Source/EffectLib/ParticleSystemData.cpp`:参考实现合并 R/G/B/A 四通道关键帧时间,在并集处逐通道线性求值;`ParticleInstance.cpp` 使用粒子已消耗寿命比例。原 Godot 实现将 RGB 固定为各轨道末值,仅 Alpha 随寿命变化,丢失中间变色。 +- Godot 4.7 `scene/resources/particle_process_material.cpp` 的生成着色器使用 `parameters.color *= texture(color_ramp, vec2(lifetime))`。此前同一 RGB 写入基础颜色和渐变,导致通道平方(如 0.5→0.25)。现在基础颜色设白色,仅在完整 RGBA 渐变中存储颜色。 +- `_color_ramp()` 合并所有通道关键帧并保留线性插值;无轨道默认 1,首尾延续最近值。对无序/重复/非有限数据做归一化,重复时间保留最后值;有效资源的寿命归一化时间语义不变。未改变混合模式或放宽现有像素基线阈值。 +- 新增 `effect_color_test.gd`:旧实现 8 项失败,修复后通过。覆盖不同通道独立拐点、常量非白色、无 Alpha 时的 RGB 动画、缺省及异常数据。另解析 warrior `palbang_4_sword`、assassin `beam_blow`、sura `pabeopsul_4_blow`、shaman `noejeon_making_1` 四份真实 MSE,把颜色轨道经过材质构建后与独立标量插值对照,共 21 组粒子 × 41 时间点 = 861 采样,headless / Metal 均 failures=0。 +- 真实资源测试有意只装配颜色轨道,未加载原贴图或发射几何;测试检查 Gradient 与材质配置,不是最终 GPU 粒子像素、色彩空间、纹理采样精度或 40250 8-bit 颜色量化等价验收。 +- `effect_playback_test`、`effect_render_regression_test`、`fx_test`、`skill_fx_test`、`player_skill_test`、`damage_effect_test`、`combat_fx_test` 均通过;部分既有资源/战斗测试的 ObjectDB 泄漏警告仍在,新颜色测试无泄漏警告。`git diff --check` 通过。 +- 剩余优先项:其他 D3D 混合/ColorOperationType、发射方向/速度/尺寸/重力等时间曲线、贴图逐粒子动画、技能挂点与动作时序,以及四职业多等级真实施法对比。R0 配对基线、R1/R2 高级装备/坡地/混合动作/GPU/NPC、R3 世界光照材质、R4 UI、R5 最终 mac 包、R6 性能与泄漏仍未完成。本轮未提交、未打包、未联机验收。 + +### 2026-09-08 粒子宽高与寿命缩放修复 + +- 对照 `ParticleSystemInstance.cpp` / `ParticleInstance.cpp`:TimeEventSizeX/Y 是出生时的独立半宽/半高,顶点沿两个方向分别加减 half-size × ScaleX/Y。旧实现用 SizeX 单值和 1×1 Quad,忽略 SizeY,还加入 0.7–1 的随机缩放与强制最小尺寸。 +- 现 Quad 完整尺寸为各轴 half-size × 2 × 0.01 米,统一随机缩放设 1,零尺寸不再强制放大。使用 Godot 支持的 CurveXYZTexture 保留独立 ScaleX/Y,Z 为 1;线性切线替换默认平滑插值,曲线范围随数据确定,不再固定到 0–2。缺失缩放轨道采用常量 1,而不是自动缩到零。 +- **范围限制**:SizeX/Y 的发射器时钟采样仍取末值,不是每个粒子出生时采样;本轮修复独立轴与静态尺寸,不声称出生尺寸时间曲线已还原。 +- 真实 `palbang_4_sword` 含同时间不同缩放值。`Type.h` 的 GetTimeEventBlendValue 在命中重复时间时取第一值,之后从最后值插值。现保留两侧,用最多 0.0001 归一化寿命的窄过渡近似跳变(纹理无法表达零宽跳变);GPU LUT 还会有采样平滑。跳变像素仍待验。此前颜色轨道的重复时间去重策略也应进一步核对,不能视为完整突变语义一致。 +- 新增 `effect_scale_test.gd`:修前 5 项失败,修后 headless / Metal 均 failures=0。合成用例覆盖独立宽高、去随机缩放、线性插值、超过 2 的缩放、零尺寸与缺省;四职业真实 MSE 共 21 组粒子、130 个关键帧/中点检查通过。仅跳变后的中点允许 <0.001 缩放值误差以容纳上述窄过渡,其余仍使用近似相等;未修改既有人物像素阈值。真实测试仅装配尺寸/缩放,不加载原贴图,也不是施法截图验收。 +- 颜色、播放时序、透明混合、fx/skill/player_skill/damage/combat 回归通过。既有资源与战斗测试退出泄漏警告仍存在。未修改 C++、未提交、未打包、未联机施法验收。 +- **当前未完成**:技能出生尺寸与方向/速度/重力等时变参数、其他混合/颜色操作、重复关键帧精确行为、逐粒子贴图动画、挂点与动作同步;R0 同机位 40250 对比;R1/R2 高级装备/混合动作/坡地/NPC/GPU 精度;R3 光照偏暗与世界材质;R4 选角和技能栏完整交互;R5 最新 mac 包和登录验收;R6 多角色性能与资源泄漏。 + +### 2026-09-08 粒子恒速旋转与 billboard 修复 + +- 核对 EffectLib `ParticleSystemData.cpp`:CW 使用 RotationSpeed,CCW 使用负值,无额外随机减速;`ParticleSystemInstance.cpp` 读取 RotationRandomStartingBegin/End。Godot 4.7 ParticleProcessMaterial 恒定角速度属性同样使用度/秒,着色器内部再转弧度。去除提前 deg_to_rad 和恒速模式的 0.3–1 随机倍率,恢复初始角度范围;NONE 不自动旋转,但仍保留初始角度。 +- 相机朝向的粒子(BillboardType=1)改为 BILLBOARD_PARTICLES,使绘制着色器读取 INSTANCE_CUSTOM.x 的角度;普通 mesh 保留 BILLBOARD_ENABLED,不读取不存在的粒子 custom data。 +- 核对 `EffectLib/Type.h`:固定 Y 轴为 2,旧映射错误地使用 4,现修正粒子/网格共用映射。粒子 3=LIE、4=2FACE、5=3FACE 仍近似为普通相机朝向,未实现原几何与方向;Y 轴及无 billboard 模式下的粒子自旋也仍需独立实现/视觉验证。 +- 新增 `effect_rotation_test.gd`:初版负对照修前 8 项失败,修后通过;补测正确 Y 轴枚举、mesh 不受粒子路径影响,以及真实萨满 `7budongbakbu_throw` 中 2 组恒速粒子,headless / Metal 均 failures=0。真实测试只检查材质/旋转参数,不加载原贴图,不是最终旋转方向或逐帧像素验收。 +- 颜色、尺寸、播放、透明混合、fx/skill/player_skill/damage/combat 回归均通过。已有资源/战斗测试仍有退出泄漏警告,新旋转测试无该警告。`git diff --check` 通过。 +- **仍未完成**:TIME_EVENT 转速仍取末值,RANDOM_DIRECTION 仍未按原实现逐粒子选择等幅正/反转;铺地/交叉面/拉伸 billboard、出生尺寸与运动时间曲线、其他混合/颜色操作、贴图动画、挂点同步与实机技能对比。R0 配对基线、R1/R2 高级装备/坡地/GPU/NPC、R3 世界光照材质、R4 UI、R5 最新 mac 包、R6 性能和泄漏继续待办。本轮未提交、未打包、未联机验收。 + +### 2026-09-09 TIME_EVENT 粒子转速曲线 + +- 将 RotationType=1 从取 TimeEventRotation 末值改为 CurveTexture 寿命采样,保留正负速度、过零与末帧停止;缺失轨道默认零,不使用 RotationSpeed 回退。共用线性曲线构造新增 fallback 参数,尺寸曲线仍默认 1,不改变上一轮尺寸行为。 +- 对照 EffectLib `CRotationDecorator` 的 speed × elapsed 积分,以及 Godot 4.7 ParticleProcessMaterial 生成着色器:带 angular_velocity_curve 时,纹理值 × 基础速度 × DELTA 直接累积至弧度 CUSTOM.x;无曲线恒速分支另做 degree_to_rad。因此曲线保留资源的度/秒值、基础乘数固定 PI/180;恒速模式继续使用度/秒,不混用两条路径。此适配与 Godot 4.7 实现绑定,升级引擎应重新核对。 +- 扩展 `effect_rotation_test.gd`,修前新增两项曲线存在性断言失败,修后通过。合成 180→-180→0 曲线验证过零、负速度、末帧归零,以及两秒寿命加初始 30° 后积分为 -60°;空轨道为零。真实萨满 jeungsok_blow、8chojeonbu、jeungryeok_hand 覆盖 3 组 TIME_EVENT / 3 个关键帧(这些真实样本各为单关键帧,变化曲线由合成用例覆盖)。headless 与 Metal 均 failures=0。 +- 测试验证配置、曲线采样与 CPU 数值积分,不直接读取 GPU 粒子角度;不代表实际逐帧积分误差、GPU 曲线纹理采样精度或 40250 画面方向已验收。原始多变速真实技能与长时间/多帧率视觉回归仍待补。 +- 尺寸、颜色、透明混合、播放时序及 fx/skill/player_skill/damage/combat 回归通过,`git diff --check` 通过;既有资源/战斗测试退出泄漏警告仍在。未提交、未打包、未联机验收。 +- **当前剩余**:随机等幅正反转、铺地/交叉面/拉伸及非相机朝向粒子自旋;出生尺寸与方向/速度/重力时间曲线;其他混合/颜色操作、重复关键帧精确行为、逐粒子贴图动画与挂点同步;R0 40250 配对基线,R1/R2 高级装备/混合动作/坡地/NPC/GPU 像素,R3 光照偏暗与世界材质,R4 选角/技能栏交互,R5 最新 mac 包和真实登录,R6 性能与泄漏。 + +### 2026-09-09 特效双面显示与深度状态 + +- 核对 `EffectLib/EffectInstance.cpp::OnRender()`:粒子与网格效果共同使用 D3DCULL_NONE、ZWRITEENABLE=FALSE。现 `_build_particle()` 和 `_mesh_material()` 显式设置 CULL_DISABLED、DEPTH_DRAW_DISABLED;保留默认深度测试,不影响角色、地形、建筑材质。 +- 新增 `effect_surface_test.gd`:用实际构造的粒子/网格材质绘制白色静态 Quad,Metal 分别从正面和背面读取中心像素,再放入不透明黑色物体确认遮挡。旧代码 4 项材质状态断言和 2 项背面像素断言失败,共 6 项;修后 failures=0,两个材质的双面可见及双向世界遮挡共 8 项像素检查通过。headless 仅运行状态断言,显式跳过像素检查。 +- 测试使用白色替代纹理、静态 Quad 和两个机位,隔离材质剔除行为;未覆盖真实 MDE 几何、动态粒子、透明物体排序、最终游戏画面。深度写入由属性断言验证,未把原默认透明材质描述为必然写入深度。 +- 旋转、缩放、颜色、透明混合、播放时序及 fx/skill/player_skill/damage/combat 回归均通过;已有资源/战斗退出泄漏警告仍在,新表面测试无该警告。`git diff --check` 通过。本轮未提交、未打包、未联机验收。 +- 材质调查记录:assets 中 ColorOperationType 计数为 2:5、3:12、4:5596、5:354、6:6、8:1(字段出现次数,非去重技能数)。当前 StandardMaterial 主要对应 4 的调制逻辑,其他颜色操作仍未实现;特别是 5/6 的加倍调制不能仅通过修改混合模式替代。本轮未改变这些颜色操作,留待独立着色器及像素回归。 +- **仍未完成**:上述其他颜色操作/混合、随机正反转、铺地/交叉面/拉伸 billboard、运动与出生尺寸时间曲线、贴图动画/重复关键帧精确行为、技能挂点同步与真实施法对比;高级装备/混合动作/坡地/NPC/GPU 画面;角色偏暗及世界光照/地形/建筑植被材质;选角与技能栏完整交互;40250 配对基线、最终 mac 包与登录验收、多角色性能和资源泄漏。 + +### 2026-09-09 粒子双倍/四倍颜色调制 + +- 实现 D3DTOP_MODULATE2X(5) / MODULATE4X(6)。新增 `fx/particle_color_material.gd`,RGB 为 clamp(texture.rgb × particle.rgb × 2/4, 0, 1),之后交给 framebuffer 混合;Alpha 仍为 texture.a × particle.a,不随 RGB 倍率放大。操作 4 保留既有 StandardMaterial 路径,未改变其他颜色操作的近似。 +- 新材质复用现有混合模式选择(mix/add),保留粒子旋转、相机 billboard、固定 Y 和独立轴缩放;双面绘制、不写深度但保留深度测试。铺地/交叉面等原有近似未因此还原。着色器按混合模式缓存,仅粒子操作 5/6 进入新路径,网格特效材质不受影响。 +- 新增 `effect_color_operation_test.gd`:临时恢复原材质选择作负对照,5 项配置/真实资源断言失败;恢复修复后通过。真实 `noejeon_4_blow` 中一组 operation=5、Y billboard 粒子正确选择新材质(真实检查不加载原纹理)。 +- Metal 像素检查:操作 4/5/6 × mix/add 共 6 组,用白纹理、已知顶点色及 Alpha=0.5、黑背景绘制。检查 RGB 饱和发生在透明混合之前且 Alpha 不放大;将输出 sRGB 转线性后,RGB 向量误差 <0.025,全部通过。白纹理隔离颜色运算,不覆盖真实非白贴图色彩空间、彩色背景或 40250 非线性色彩管线等价性。 +- 扩展 `effect_surface_test.gd`,加入两种新材质的正反面可见和世界遮挡;四种材质共 16 项 Metal 像素断言通过。headless 仅验证颜色操作配置,明确跳过像素。动态 GPUParticles 的旋转/缩放与复杂透明排序仍需场景级画面验收。 +- 颜色、尺寸、旋转、透明混合、播放时序与 fx/skill/player_skill/damage/combat 回归均通过,`git diff --check` 通过。已有资源/战斗退出泄漏警告仍在,新颜色操作/表面测试无该警告。未提交、未打包、未联机验收。 +- **当前未完成**:SELECTARG1/2、ADDSIGNED 等其他颜色操作及更多混合对;随机正反转、铺地/交叉面/拉伸、出生尺寸和运动时间曲线、贴图逐粒子动画、关键帧突变精度与挂点同步;R0 40250 同机位基线;R1/R2 高级装备/混合动作/坡地/NPC/GPU 像素;R3 角色偏暗与世界材质光照;R4 选角/技能栏完整交互;R5 最新 mac 包/登录;R6 多角色性能和泄漏。 + +## 2026-09-09 集中修复、整套回归与 mac 实包验收 + +### 本轮已完成的代码修复 + +1. **补齐现有资源中的颜色操作**:SELECTARG1(2) 取粒子色、SELECTARG2(3) 取纹理色、ADDSIGNED(8) 为两色相加减 0.5,RGB 饱和后混合,Alpha 仍单独相乘。与已有 4/5/6 共覆盖此前资产调查发现的六类 ColorOperationType;不等于所有 D3D 混合因子已支持。扩展白/彩色纹理 × 六操作 × mix/add 共 24 项 Metal 像素检查,保留原误差阈值。 +2. **发射生命周期**:EffectPlayer 的 one_shot 仅控制特效实例回收,GPUParticles3D 不再用一次粒子寿命终止发射。短寿命粒子在 MSE 发射窗口内持续补充,窗口结束仍停止。新增实时后端测试:粒子寿命 0.05s、发射窗口 0.8s,在 0.3s 仍发射,0.9s 后停止。 +3. **附着粒子**:读取原字段 AttachEnable,映射 local_coords,区分粒子随发射器移动和留在世界空间。该配置修复不代表完整技能骨骼挂点/坐标轴与动作事件同步已验收。 +4. **技能栏冷却**:按技能 ID 保存会话内冷却,服务端槽位刷新、同技能多格、换格/重新放入不再清空冷却遮罩;冷却结束包清理共享状态。技能 UI 回归覆盖重复技能格、服务端恢复、提前解锁及原有翻页/移动端行为;未实现跨断线恢复的服务端剩余冷却同步。 +5. **DDS 真正缺图根因**:真实 `shamanblackcore.dds` 为 ARGB1555,旧解码器无法读取。现按 DDS 位掩码和行 pitch 解码非压缩 16/24/32 位 RGB,包括 A1R5G5B5、R5G6B5、A4R4G4B4、24 位和不同 32 位通道顺序;无有效 alpha 标志按不透明处理。拒绝重叠/非连续掩码、无效 pitch、截断输入;DXT1/3/5 保留。新增 C++ 用例经 AddressSanitizer/UndefinedBehaviorSanitizer 通过。此为 level-0 解码,不表示全 DDS 格式/mipmap 支持。 +6. **资源生命周期**:UiAssets 的 Metin2World 解码辅助节点现同步释放,返回 Image 独立存活;真实 DDS 连续 20 次解码节点计数不增加。combat_fx_test 的两个孤立假节点改为正确归属树(这是测试夹具修复,不误记为生产泄漏修复)。 + +### 综合回归证据 + +- 新增入口 `bash script/rendering_batch_test.sh`,保留每项完整日志,检查退出码、成功标记和 GDScript 错误;遇到失败继续收集其余结果。最初运行发现测试夹具干扰和 macOS Bash 空数组兼容性问题,修复后重跑。 +- 最终:**21 项 headless + 7 项 Metal = 28 项全部通过**,日志目录 `build/rendering/batch-20260909-072138-35505/`。覆盖特效、技能栏、施法校验、战斗、场景与登录生命周期、换装规则、八种族动作装配、CPU 像素基线、GPU LOD 附件与动态包围盒;本次最终批次未出现退出泄漏警告。 +- `bash script/rendering_test.sh` 重建/同步 debug 扩展与选角装配通过,人物 CPU 像素基线未调整。native 测试:`clang++ -std=c++17 -Wall -Wextra -fsanitize=address,undefined -Iextension/src extension/tests/dxt_decode_test.cpp extension/src/dxt.cpp -o build/dxt_decode_test && build/dxt_decode_test`。 +- `git diff --check` 通过。全部原有未提交修改已保留,本轮未提交。 + +### 新 mac 包与实际进场 + +- 独立产物:`build/export-rendering-20260909/mtgodot-poc.app`,保留 `build/export/` 原包。`build-macos-client.sh` 新增 MT_MAC_OUTPUT_DIR,便于输出到独立目录;这次是 release、arm64+x86_64 universal、ad-hoc 签名,codesign deep/strict 检查通过。实际运行验证平台为 Apple M4,Intel 平台未实测。 +- release 扩展 SHA-256:`683aad5a97a0e6b0c959b082bb3774950d12565b4a6e0c216a1f53eb9a1d2120`;PCK SHA-256:`9cd602618e76debad02aa6a5544ac61df498ded1e3cebe5351d962800d0be023`。 +- **包内离线 PASS**:清除 MT_ASSETS,资源实际来自 Contents/Resources/assets;真实 A1 地图、角色、DDS 和专用技能材质均加载成功。报告/截图 `build/rendering/package-20260909/report.json` / `game.png`,脚底 gap=0.001373m,20 块地形加载、0 块失败。额外技能检查为资源/材质装配,不是施法画面。 +- **真实只读登录 PASS**:用新包连接现有 40250 服务,9.633 秒完成登录和主角/世界/属性/背包/技能/快捷栏接口检查。`build/rendering/live-20260909/live-smoke-report.json`,mutations=false、reconnect=false;未发送测试移动/聊天/施法/换装。测试脚本支持独立 APP/输出路径,遇到已有客户端会退出提示,不再杀掉用户运行中的客户端。 +- **实际游戏截图**:`build/rendering/live-20260909/game.png`。第二次普通自动登录后只观察,C1 主角就绪(main_attempts=1),后续多次采样位置稳定;人工复核画面包含主角、地表、NPC/建筑、小地图与底部技能图标。没有用离线截图替代此实机证据。 +- **未解决警告**:包内离线整场景测试退出时仍出现 3 个 shader RID 释放警告,尽管测试报告 PASS;不能宣称全场景资源释放已完成。源码已有世界 shader 清理函数,具体退出时序/引用来源仍待定位,未用屏蔽日志处理。 + +### 仍需继续的工作(不能记为本轮完成) + +- **技能完整运行时**:随机等幅正反转、铺地/交叉面/拉伸 billboard、非相机朝向自旋、运动与出生参数时间曲线、逐粒子贴图动画、重复关键帧突变精度、骨骼挂点/动作事件与真实四职业多等级施法验收;更多 D3D blend 对仍为近似。 +- **人物/环境保真**:全高级装备与武器发型组合、混合动作、坡地/台阶、NPC/怪物/GPU 画面;角色与环境光照、雾/阴影、地形/水/建筑/植被和完整选角/UI 与 40250 的受控对照。SpeedTree 仍为 proxy,不能以本轮 DDS 支持替代完整树模型还原。 +- **交互与稳定性**:多分辨率/完整技能栏输入拖放、跨重连状态与切图/换角色实机压力测试;本轮只读登录没有覆盖这些外部状态变更。 +- **性能/发布**:多角色帧时间和 GPU 包围盒 CPU 成本、完整场景 shader RID 警告;Intel Mac、正式签名/公证、全量游戏流程验收。 +- **验收所缺证据**:光照与材质精调仍需要 40250 同资源/角色/坐标/相机/动作/环境的参考帧,不能仅凭现有不同机位截图任意调亮后宣称一致。R5 的“有新包/独立加载/只读登录”本轮已完成,R0–R6 的全部保真门禁尚未完成。 + +## 2026-09-09 后续修复:动作挂点、逐粒子贴图、NPC 绕序及水面 + +本节为以上历史批次之后的最新进展。**五组问题尚未全部关闭**;配置/数值测试、Metal 像素测试、实包退出和 40250 配对验收分别记账。 + +### 已修复 + +1. **MSA 动作特效挂点数据链**:两条 MSA 解析入口均保留 AttachingBoneName、AttachingEnable、FollowingEnable、IndependentFlag;native `get_events()` 和新增 `motion_event_detailed` 携带完整信息,旧四参数信号保留。GameScene 优先订阅详细信号,声音/射箭仍只分派一次。普通 EFFECT(type=1) 保留完整资源路径,不再取 basename 猜文件;使用实际动画混合后的全仿射骨骼矩阵区分跟随、世界快照、独立效果和根节点附着。偏移保持源码 `bone * translation * actor` 的行向量组合顺序,再转换厘米/Z-up;缺失骨骼不再误放到角色根。跟随节点在动画之后更新,角色销毁时随之回收。**尚不覆盖** EFFECT_TO_TARGET(type=10)、远端角色全部动作事件、所有 MSE 内部坐标/旋转与真实施法同步。 +2. **逐粒子贴图动画**:移除粒子共用 AnimatedTexture 时钟,原纹理装成 Texture2DArray;使用粒子自身 CUSTOM.y 年龄、CUSTOM.z 出生随机种子,在绘制 shader 中选择帧。支持 NONE/CW/CCW/RANDOM_FRAME/RANDOM_DIRECTION、随机起始帧及延时阈值;倒放从第 0 帧开始,随机方向的倒放从末帧开始,依据 ParticleSystemInstance.cpp。随机序列不承诺与旧客户端 RNG 逐帧相同;寿命目前仍是既有单发射器近似,不等于出生参数时间曲线已还原。 +3. **NPC/怪物外表面被剔除**:截图复核发现 MobView 未应用 PC 已使用的 flip_winding。真实野狗(101)、熊(110)、炼金师(20001) 的 CPU/GPU 六组几何法线负对照修前均失败(面积加权绕序/法线比值约 +0.93~+0.97),修后约 -0.93~-0.97、全部通过。不以三个样本代表所有 NPC/怪物资源。 +4. **水面明确的源码差异**:MapOutdoorWater.cpp 使用纹理 RGB 与顶点 diffuse alpha,不含原实现附加的 tint/Fresnel/PBR 光照。现恢复直接取纹理色与顶点透明度;AreaTerrain.cpp / MapOutdoor.cpp 的 raw 水深除以 400、上限 0.8 替代原 60cm/最低 0.12 的公式,正确考虑 HeightScale。按 2m 水格保留内部岸线采样,避免跨整行合并丢失岸线;四角透明度均为零时不生成水格。保留 30 帧/70ms 序列。**仍有近似**:水面高度浮动、远雾处混合状态及整体色彩空间尚未与受控参考帧对齐。 + +### 回归与证据 + +- 原整套回归扩为 **25 headless + 8 Metal = 33 项 PASS**,日志 `build/rendering/batch-20260909-163241-59569/`;本批未出现退出泄漏警告。新增 motion_effect_anchor / mob_winding / water_reference / effect_texture_animation 测试。 +- `motion_effect_anchor_test.gd` 使用真实武士骨架与 gigongcham.msa,验证骨骼位置、角色转向下的厘米偏移、跟随/快照/独立/缺骨行为及文件解析元数据;不是完整施法截图验收。 +- `effect_texture_animation_test.gd` 用同一材质、两个不同年龄的实例在 Metal 读取像素,覆盖五种动画类型和三个年龄点;新生实例保持起始帧,老实例按自身年龄选帧。合成纹理用于隔离帧选择,不冒充真实四职业技能画面;随机起始帧与随机分布尚无统计验收。 +- 水深 native 单测经 ASan/UBSan 通过;最终水格剔除优化后另跑 `water_reference_test.gd`:**15 块水面、212587 个顶点对照、11898 个浅岸样本通过**。RGBA8 顶点色容差为 1/255;最外地图边界 1149 个顶点不调用跨区块 `sample_height` 对照(其邻块不存在),明确计入 skipped。它验证几何/材质,不是水面配对像素。 +- `formats_msa_test` 重编通过;`script/rendering_test.sh` 重建并同步 debug 扩展、选角检查通过;`git diff --check` 通过。未提交,既有未提交内容保留。 +- `rendering_scenario_test.gd` 为独立、离线、真实资源场景报告:A1、CPU/GPU 两路径额外 1/8/16 个角色、run→attack→wait、野狗/熊/炼金师,1280×720 / 1920×1080 / 2560×1440 技能栏边界与截图,以及 world_reset + warp 信号驱动的 A1→C1。输出 `build/rendering/scenarios-followup/report.json` 与三张 `scene-*.png`。技能栏没有来自服务器的技能数据,不能将空槽截图称为完整技能交互验收。每组 90 个窗口帧间隔受垂直同步影响,不是 GPU timestamp、无帧率上限基准或大量角色性能保证。 + +### shader RID 警告:已定位范围,未修复 + +- 原包复跑 `exit-export-repeat.log` 重现 **3 个 ParticlesShaderRD / Shader RID** 警告。源码编辑器相同离线进场没有该警告。 +- 实包诊断中仅跳过粒子预检,地图/角色仍完整加载:`exit-no-effects.log` 不再出现警告。由此定位到粒子 process material 路径,不是凭“正好三个”推测为地形/树/水 shader。 +- 等待实际渲染帧、提前解除 emitter 的 process_material 引用、提前清理世界缓存均未消除实包警告;无效的生产清理改动已撤回,也没有屏蔽日志或将跳过粒子作为正式通过方式。保留正常进树/渲染/释放的包内预检。 +- 引用计数诊断显示释放 emitter 后仅剩测试数组/循环局部引用;仍需进一步检查 release 模板的粒子 shader 缓存/销毁路径,不能据此断言已经修复或一定属于某个上游缺陷。 + +### 当前剩余清单 + +1. **技能**:运动/出生参数时间曲线、径向发射、随机等幅自旋、铺地/交叉面/拉伸等特殊 billboard、内部坐标/旋转一致性、目标特效与远端事件、更多 D3D blend 对、四职业多等级真实施法/挂点画面验收。 +2. **人物**:全部高级护甲/武器/发型组合、混合动作逐帧像素、坡地/台阶足部贴合、更多 NPC/怪物及 GPU 最终画面;本轮 crowd 的有限数值/稳定性覆盖不等于全部通过。 +3. **环境保真**:40250 同资源/相机/动作/天气参考帧下的角色亮度、光照/阴影/雾、地形、水面、建筑植被;SpeedTree 仍为近似树。完整树几何需要可在目标平台使用、授权明确的导出/替代资源链,不能拿现有代理模型宣称还原。 +4. **交互与性能**:真实重连/切图/换角色压力、跨重连冷却状态、多分辨率鼠标/键盘拖放与技能释放全流程、更大人数与长期帧时间/内存测量。离线 warp 测试不是联网重连验收。 +5. **释放与发布**:实包三 shader RID 警告尚在;Intel Mac、正式签名/公证和全流程发布验收未完成。 + +### 本轮最终实包与发布门禁 + +- 新产物 `build/export-rendering-followup-20260909/mtgodot-poc.app`(universal release,ad-hoc,codesign deep/strict 通过);保留之前的 `build/export-rendering-20260909/` 和 `build/export/`。本轮未重新联网登录,不能沿用上一批次登录结果宣称本包已完成联网验收。 +- 包内离线加载报告 `build/rendering/package-followup-final/report.json` 为 PASS:20 块地形、18 块非全透明水面、2 个专用技能材质,脚底 gap=0.001373m。完整日志 `build/rendering/package-followup-final.log` 在报告之后仍出现 3 shader RID 警告,**因此干净退出门禁未通过**。 +- release dylib SHA-256:`63c5c0a639d5c531c0f28d64f9283c536a491156a1d29fa1b6ae931fdc826303`;PCK:`bcae262f6e91d82e79a7c0618750f1bed1325c5e7f7a043bb25d751d32918816`。 +- 新增 `script/package_render_test.sh`:需同时满足进场成功标记、进程正常返回、完整日志无 script/renderer 错误或泄漏,才返回成功。可用 `MT_RENDER_APP` 指定包、`MT_RENDER_OUTPUT` 指定报告目录。本包检查日志保存在 `build/rendering/package-followup-gate/client.log`,已知泄漏必须让检查失败,而不是过滤日志或只读 JSON 的 passed 字段。 +- 最终离线场景报告 failures=[],截图复核的 2560×1440 帧中野狗、熊、炼金师表面可见;早期小分辨率帧仍会记录模型刚创建时的 LOD 渐变,不是固定姿态参考帧。16 个额外角色样本:CPU median=16.666ms / P95=16.872ms / max=47.342ms;GPU median=16.705ms / P95=17.862ms / max=51.997ms。动作切换仍有长帧,不能宣称“稳定 60fps”或“GPU 更快”。 + +## 2026-09-10 续修检查点(以此节更新上述历史剩余清单) + +### 已实现与专项验证 + +- **目标特效**:MSA type10 携带 FishingEffectFlag;快捷栏成功施法时捕获目标,不随后续选中对象改变。跟随效果用目标变换,快照效果使用目标位置加世界偏移,目标消失后释放跟随节点。钓鱼落点尚未接入,明确不在施法者脚下误生成;快照中心仍为可见网格包围盒近似,不是完整旧版包围球还原。 +- **坐标与水平面特效**:MSE 粒子/网格/灯光移动、Bezier 控制向量、发射方向与 MDE 顶点统一为厘米 Z-up → 米 Y-up。LIE 使用水平面 shader,Metal 俯视可见/侧视边缘负控通过。 +- **双面/三面粒子**:新增 `particle_faces.gd`,同一粒子模拟驱动两个/三个面,面角通过 UV2 传入 shader;对应旧版 TwoSideRenderer 的 ±30°、ThreeSideRenderer 的 0°/±60°,不创建随机状态不同的重复发射器。`effect_faces_test.gd` 验证面数、角度、材质,以及两个相机方向下的 Metal 投影宽度。拉伸、复杂挂接与各技能实景仍需验收,不能把有限投影测试当作全技能像素一致。 +- **动作缓存**:不可变 GR2 解码缓存上限 32 条/64MiB section 数据;MSA 元数据缓存上限 128 条,按路径/修改时间失效。各角色时间、事件游标与姿态独立,清空缓存不破坏活动动作;失败不缓存。删除 reload 中同一 MSA 的第二次解析。专项测试两角色三种动作为 GR2 17 hits/3 misses、MSA 17 hits/3 misses。GR2 上限不含解析对象开销及活动角色仍持有的淘汰条目;MSA 为条数上限,不是总内存硬上限。 +- **水面移动**:删除按顶点空间位置变化的正弦波,改为旧版整片共享垂直位移;初始 300ms 静止,随后 1000–3000ms 线性过渡,目标在随机 0..−15cm 与零之间切换。`water_motion_test.cpp` 在 ASan/UBSan 下验证端点、时长及长帧不越界;随机时序未与 Windows 同步,不宣称逐帧一致。远处水面关混合/雾过渡仍未实现。 +- **真实重连历史证据**:`build/rendering/reconnect-20260909/live-smoke-report.json` PASS,24.009 秒、mutations=false、reconnect=true,两次进入游戏并重建主角。该实包早于最新坐标/多面/水面修改,不可当作当前最终包的联网验收;不是断网故障或长期重连压力测试。 + +### 回归证据与边界 + +- 扩展重建、debug dylib 同步签名、选角测试通过;新增测试纳入 `script/rendering_batch_test.sh`。 +- 最新完整回归 **39 项通过**(29 headless + 10 Metal):`build/rendering/batch-20260910-213214-9037/`,汇总 `followup-final-batch-20260910.log`。包含目标、坐标、缓存、多面、LIE 以及既有模型/材质/技能回归。 +- 离线场景报告 `build/rendering/scenarios-20260910/report.json` failures=[],覆盖真实 A1→C1、1/8/16 个额外角色 CPU/GPU、三分辨率布局;仅是短窗口帧间隔,不是冷启动/长期性能证明。本轮进程诊断可能与此轮场景采样时间相邻,性能结论以随后隔离复测为准。 +- **释放仍未关闭**:`particle-exit-primed.log` 证明提前调用 get_rid 再配置仍有三个泄漏,未作为生产修复。进一步按 gravity/scale/shape、保留若干帧后释放、恢复默认形状等情况拆分诊断;只认可成功标记与退出后的完整日志,外部 --script 探针没有执行标记,不能计为测试通过。 + +### 仍未完成 + +1. 实包粒子 shader RID 泄漏的真正修复及干净退出门禁。 +2. 拉伸 billboard、径向与出生参数时间曲线、随机等幅正反转、钓鱼落点、准确目标包围球中心、远端完整事件、更多混合模式,以及四职业多等级真实施法画面对照。 +3. 光照/阴影/雾/地形/建筑/植被和远处水面,与 40250 同资源/坐标/相机/动作/环境的受控像素对照;缺少这些成对帧,不能靠任意调亮验收。SpeedTree 仍是近似模型,需要授权明确且可用的几何转换或替代资源。 +4. 冷加载、首次材质/动画编译、全高级装备/混合动作/坡地/更多 NPC 验收,长期多角色帧时间及完整交互/断网恢复/重复切图压力。 +5. Intel Mac、正式签名/公证及全流程发布验收。当前代码及旧批次改动均未提交。 + +### 本次最终实包与隔离复测 + +- 新包 `build/export-rendering-20260910/mtgodot-poc.app`,保留所有旧包;universal release、ad-hoc,`codesign --verify --deep --strict` 通过。dylib SHA-256 `b4d8fc1f0d4aba0183531769c409545e8639842fc734563cacb6233e4c728fdb`,PCK `d88fb74b9db9d8ae9a0a4caa47de40c69d350864dd97046c454b82079e39aa7d`。 +- `build/rendering/package-20260910/report.json` / `game.png` 完成包内离线进场,`client.log` 有 PKGRENDER PASS,**退出后仍有三个 shader RID 泄漏,故 package_render_test.sh 正确返回失败**。这不是发布验收通过。 +- 进一步最小实验 `particle-exit-20260910-{gravity,scale,shape,held,restored}.log`:仅改重力、仅改缩放退出干净;设置发射形状后泄漏;持有五帧后释放、恢复默认形状后释放均仍泄漏。范围已缩小到形状配置触发的粒子 shader 路径,具体引擎内部根因未确认。无效清理方案未加入生产。 +- 所有构建、实包诊断结束后独立重跑 `scenarios-20260910-isolated/report.json`,failures=[]。16 个额外角色 CPU median/P95/max = 16.669/16.932/17.051ms,GPU = 16.704/16.965/17.133ms;CPU 八角色组仍有 23.809ms 最大帧。每组仅 90 个受 VSync 影响的窗口帧间隔,包含两次动作切换;不能据此保证全部冷加载或长期无卡顿,也不能将所有改善归因于单一缓存改动。 + +## 2026-09-11:干净退出构建路径、随机旋转与目标中心 + +### 粒子退出警告:新 arm64 Release 包通过,官方模板仍复现 + +- 独立工程 `test/rendering/particle-leak/` 不包含客户端扩展、autoload、地图、网络或特效播放器。八个粒子材质切换三种发射形状,WeakRef 确认存活数为零,最后执行 RenderingServer.force_sync。官方 4.7.1 Release 模板仍报告三个 Shader RID 泄漏;官方 Debug 模板退出干净。日志 `particle-isolated-release.log` / `particle-isolated-debug.log`。 +- 从上游固定提交 `a13da4feb8d8aefc283c3763d33a2f170a18d541` 构建 arm64 Release:`debug_symbols=yes optimize=speed lto=none vulkan=no`(Metal 可用)。加入计数日志的诊断构建干净退出,**移除全部诊断修改、恢复原版源码后也干净退出**,日志 `particle-engine-baseline.log`。不能据此断言某一个编译选项或某行上游源码就是根因;已确认的是当前安装模板与这条构建路径的行为差异。 +- 新增 `script/build_macos_release_engine.sh` 固定源码提交并拒绝带修改的引擎工作树,不替换系统模板。`build-macos-client.sh` 支持显式 `MT_MAC_ENGINE` 并验证架构,替换包内引擎后重新签名。示例:先运行 `bash script/build_macos_release_engine.sh arm64`,再以 `MT_MAC_ARCHES=arm64`、`MT_MAC_ENGINE="$PWD/build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64"` 和独立 `MT_MAC_OUTPUT_DIR` 调用 `bash build-macos-client.sh release`。 +- 新产物 `build/export-rendering-clean-exit-20260911/mtgodot-poc.app`:**Apple Silicon/arm64,实际 release=true、debug=false、editor=false**;不是以 Debug 模板替代 Release。ad-hoc 签名 deep/strict 通过,保留所有旧包。 +- `script/package_render_test.sh` 连续两次通过:`build/rendering/clean-exit-20260911-1/client.log`、`clean-exit-20260911-2/client.log`。包内地图、模型、DDS、三种专用特效材质正常装配,退出完整日志无 shader/RID 错误。没有跳过粒子或过滤警告。 +- **关闭范围**:这条新 arm64 构建路径的整场景退出门禁已通过;安装的官方 Release 模板仍未修复。默认不指定 MT_MAC_ENGINE 仍使用官方模板,不应发布未经退出门禁检查的包。Intel/universal 自编译模板、其他图形后端及正式签名/公证尚未验证。 + +### 特效修复与回归 + +- 随机等幅旋转:依据 ParticleSystemData.cpp 的 RANDOM_DIRECTION,在出生时选择正/反方向且保持完整角速度;去掉仅正向随机减速。绘制 shader 使用粒子年龄与稳定种子,保留初始角度,不在 process material 重复积分。覆盖普通/Y/水平/多面 billboard 路由;`effect_random_rotation_test.gd` 包含两个符号种子的 Metal 镜像画面负控。 +- 目标中心:GR2 解析保留 BoneBindings 的 OBBMin/OBBMax(验证 float3 与有限范围),通过主体骨骼 world pose 变换八个角点得到包围盒中心,首次查询后缓存,模型 reload 时失效;不合并独立发型、武器、场景特效或 LOD ghost。`fly_target_bounds_test.gd` 覆盖八种角色的 CPU/GPU 一致性、远处外接网格不改变中心、后续动作不移动缓存中心、角色整体位移正确传播。缺少有效 OBB/pose 的资源仍走显式旧近似回退;首次建球时机、所有装备/NPC 和 Windows 实际目标帧还需进一步对照。 +- **43 项回归通过**:`build/rendering/batch-20260911-062535-40433/`(31 headless + 12 Metal),汇总 `clean-exit-final-batch.log`。原有目标特效、坐标、动画缓存、多面、LIE、水面及模型检查保留。未提交 Git,原有工作树修改保留。 + +### 性能与仍未完成的内容 + +- `rendering_scenario_test.gd` 新增 `MT_RENDER_SAMPLE_FRAMES`(90..36000),每 30 帧切换动作,记录同步模型构建耗时、群体动作切换耗时、帧间隔、Godot static memory 与动画缓存。模型构建记录不是清空 OS/驱动缓存后的“真正冷启动”;static memory 也不是进程 RSS 或显存。 +- 拉伸 billboard、径向/出生参数运动曲线、钓鱼水面落点及其完整动作事件、更多混合方式、远端完整事件和四职业真实施法仍未完成。 +- 光照/阴影/雾/地形/建筑/植被、远处水面与 40250 同条件配对画面仍未完成。树木仍是代理几何,缺少授权明确的完整转换资源链;未用任意调色代替受控验收。 +- 冷启动/首次 shader 编译、全装备/坡地/NPC、长期联网重连切图和多角色压力、Intel 及发布验收仍待完成;不能把本次退出通过等同于渲染全部完成。 + +### 持续采样与最终包补充 + +- `build/rendering/soak-20260911/report.json`:309.124 秒,CPU/GPU × 1/8/16 个额外角色,每组 3000 帧、99 次群体动作切换,共 18000 帧/594 次,功能 failures=[]。各组帧间隔最大值依次为 18.458、27.558、17.846、18.025、18.093、18.218ms;CPU 八角色动作切换耗时最大 23.262ms,仍是明确长帧。各组 Godot static memory 增量约 0.07–0.14MB,包含测试数组和缓存,不是 RSS/显存或长期无泄漏证明。同步模型 build 最大 7.36ms,OS/资源缓存未清空。 +- 在这次采样结束后,进一步把每次 reload 的骨骼匹配诊断扫描和三类普通日志改为 `MTGODOT_ANIM_DIAGNOSTICS=1` 才执行;解析失败、错误与警告完全保留。旧五分钟样本是**优化前**的数据,不用于冒称优化后的长期性能。`animation-diagnostics-enabled.log` 验证显式开启仍输出诊断并通过缓存测试。 +- 最终重新构建并签名同名 arm64 测试包;43 项回归再次通过,日志目录 `build/rendering/batch-20260911-063306-41283/`。`build/rendering/clean-exit-20260911-final/client.log` 再次通过完整包内进场与干净退出门禁。 +- 最终包 SHA-256:engine `b930ded944fd51ce911e1958289f9bc46c51cad17aa62ef30ca61c69330fb50a`;dylib `6f60298bdd9e45c5047e9327de24546506a0b1ba90feb8db40b82e1ff4416b3d`;PCK `e3e38b68b12b670ce44687870785ae519c90ff283e56112190a597b594a2b779`。 +- 日志优化后的短场景复查 `build/rendering/scenarios-quiet-20260911/report.json` failures=[],仍覆盖 CPU/GPU、多角色、三分辨率和 A1→C1;只有每组 90 帧,不能替代优化后重新进行的持续或冷启动性能验收。 + +## 首个联网内测版开发入口 + +联网闭环、基础战斗、首测地图与稳定性门禁的文件级任务、接口设计、状态机、测试证据和执行命令见 [首个 Mac 联网内测版:开发实施文档](FIRST-MAC-PLAYABLE-IMPLEMENTATION.md)。该文档中的新增接口与脚本为待实现设计,不代表已经完成验收;树木及其他渲染完成状态仍以本路线的实际测试记录为准。 + +## 2026-09-11:真实静态树木模型接入 + +### 已完成(更新“树木仍为代理”的历史状态) + +- 在同工作区 `metin2-godot-main/generated/content-v1/40250-full-unpacked-final` 找到原始 SpeedTreeRT 提取产物及来源清单。87 个 SPT 原文件 SHA-256、87 个 GLB SHA-256 全部匹配当前资源;不是依名称套用其他树种,也不是新生成的近似几何。 +- `script/stage_tree_geometry.mjs` 校验来源、转换坐标约定、节点结构、内嵌贴图和双向哈希后,放入 `assets/TreeGeometry`(约 59MB)。Mac 不运行或分发 SpeedTree DLL。 +- `get_tree_mesh` 优先读取经校验的 GLB,保留真实枝干/叶状枝/叶片、UV、原始树高和树皮/composite 贴图。顶点已经为米/Y-up,不再套代理的统一 12m 高度或二次坐标转换。继续按 treefile 共享 ArrayMesh/MultiMesh,保留原 AreaData 位置和原烘焙阴影策略。 +- 叶片保留双面 alpha scissor(0.3);观察 `build/rendering/native-trees-metal/beech-0.png` / `beech-90.png` 的两个角度,树冠与枝干可见,没有把整张 atlas 画成不透明方块。 +- 原代理代码仅为缺失/损坏资源的诊断回退:打印警告、mesh/instance 标记 `tree_geometry=proxy`,地图报告新增 `native_trees_placed` 和 `proxy_trees_placed`。包内验收要求真实树数大于零且代理数为零,回退不能被当成完成。 + +### 验证与覆盖 + +- `native_tree_test.gd` 对87树种逐项核对哈希、GLB装载、surface数量、纹理、透明裁剪、双面材质及有限包围盒;另对榉树做两个机位的 Metal 轮廓检查并保存截图。 +- 六图实际加载:A1=368、A3=389、B1=317、B3=431、C1=296、C3=291,共 **2,092 棵真实树,代理回退 0**。各图已有少量建筑模型缺失不是树木缺失,本轮未改变它们。 +- 全量回归 **45 项通过**(32 headless + 13 Metal),`build/rendering/batch-20260911-134729-54151/`,汇总 `native-tree-batch.log`。原角色、技能、水面、缓存和 GPU 检查保留。 +- `script/audit_native_trees.mjs` 扫描当前资源:118 个 SPT 中87个有原生导出;PRT引用84种,其中实际静态 AreaData 放置81种,**missing_placed=[]**。报告 `build/rendering/native-tree-coverage.json`。 +- 新包 `build/export-native-trees-20260911/mtgodot-poc.app`,Apple Silicon/arm64 Release,使用上一节验证过的自编译引擎。codesign deep/strict 通过;`build/rendering/native-trees-package/client.log` 的完整进场/退出门禁通过,包内 A1 native=368、proxy=0,没有新增 shader RID 警告。旧包保留,未提交 Git。 + +### 树木相关剩余边界 + +- 本轮完成的是**真实静态模型**;原版 SpeedTree 的逐叶相机朝向、风场/枝叶动画和原生 LOD 切换仍未恢复,不能宣称完整 SpeedTree 视觉行为已一致。 +- `Zone/ymir work/zone/b/tree` 中31个旧 SPT 未转换,当前 PRT 未引用这些路径,不借相似名字冒充匹配。一个未被静态 AreaData 使用的旧 PRT 指向不存在的 `christmastree.spt`;真实季节变体 `christmastree1/2/3.spt` 已转换,活动树生成/等级特效属于另一个流程。 +- 六图以外的逐地图 GPU 画面、与40250同条件像素对照,以及更大规模树木长期性能仍待验收。 diff --git a/extension/src/dxt.cpp b/extension/src/dxt.cpp index cb6c2d2f..1e926acf 100644 --- a/extension/src/dxt.cpp +++ b/extension/src/dxt.cpp @@ -67,7 +67,19 @@ void decode_dxt5_alpha(const uint8_t* blk, uint8_t out[16][4]) { } } -enum Fmt { F_NONE, F_DXT1, F_DXT3, F_DXT5, F_BGRA8 }; +enum Fmt { F_NONE, F_DXT1, F_DXT3, F_DXT5, F_RGB }; + +bool valid_mask(uint32_t mask, uint32_t bits) { + if (!mask || (bits < 32 && (mask >> bits))) return false; + while (!(mask & 1)) mask >>= 1; + return (mask & (mask + 1)) == 0; // contiguous channel bits +} + +uint8_t channel(uint32_t pixel, uint32_t mask) { + if (!mask) return 255; + while (!(mask & 1)) { mask >>= 1; pixel >>= 1; } + return uint8_t((uint64_t(pixel & mask) * 255 + mask / 2) / mask); +} Image decode(const uint8_t* d, size_t len) { Image img; @@ -85,8 +97,8 @@ Image decode(const uint8_t* d, size_t len) { if (!std::memcmp(fourcc, "DXT1", 4)) fmt = F_DXT1; else if (!std::memcmp(fourcc, "DXT3", 4)) fmt = F_DXT3; else if (!std::memcmp(fourcc, "DXT5", 4)) fmt = F_DXT5; - } else if ((pf_flags & 0x40) && rgb_bitcount == 32) { // DDPF_RGB, 32bpp - fmt = F_BGRA8; // Metin2 的非压缩 DDS 一般是 B8G8R8A8 + } else if ((pf_flags & 0x40) && (rgb_bitcount == 16 || rgb_bitcount == 24 || rgb_bitcount == 32)) { + fmt = F_RGB; } if (fmt == F_NONE || w == 0 || h == 0 || w > 8192 || h > 8192) return img; @@ -96,15 +108,25 @@ Image decode(const uint8_t* d, size_t len) { img.h = uint16_t(h); img.rgba.assign(size_t(w) * h * 4, 0); - if (fmt == F_BGRA8) { - if (avail < size_t(w) * h * 4) { img = Image{}; return img; } - for (size_t i = 0; i < size_t(w) * h; ++i) { - img.rgba[i * 4 + 0] = src[i * 4 + 2]; - img.rgba[i * 4 + 1] = src[i * 4 + 1]; - img.rgba[i * 4 + 2] = src[i * 4 + 0]; - img.rgba[i * 4 + 3] = src[i * 4 + 3]; + if (fmt == F_RGB) { + const uint32_t r = rd_le32(d + 92), g = rd_le32(d + 96), b = rd_le32(d + 100); + const uint32_t a = (pf_flags & 1) ? rd_le32(d + 104) : 0; + if (!valid_mask(r, rgb_bitcount) || !valid_mask(g, rgb_bitcount) || !valid_mask(b, rgb_bitcount) || + (a && !valid_mask(a, rgb_bitcount)) || ((pf_flags & 1) && !a) || + (r & g) || (r & b) || (g & b) || (a & (r | g | b))) return {}; + const size_t bytes = rgb_bitcount / 8, row_bytes = size_t(w) * bytes; + const size_t pitch = (rd_le32(d + 8) & 8) ? rd_le32(d + 20) : row_bytes; + if (pitch < row_bytes || avail < row_bytes || pitch > avail || (h - 1) > (avail - row_bytes) / pitch) return {}; + for (size_t y = 0; y < h; ++y) { + for (size_t x = 0; x < w; ++x) { + uint32_t pixel = 0; + for (size_t k = 0; k < bytes; ++k) pixel |= uint32_t(src[y * pitch + x * bytes + k]) << (8 * k); + auto* out = &img.rgba[(y * w + x) * 4]; + out[0] = channel(pixel, r); out[1] = channel(pixel, g); + out[2] = channel(pixel, b); out[3] = channel(pixel, a); + } } - img.format = "BGRA8"; + img.format = rgb_bitcount == 32 && r == 0xff0000 && b == 0xff ? "BGRA8" : "RGB_MASKED"; return img; } diff --git a/extension/src/dxt.h b/extension/src/dxt.h index 74f92c25..81666116 100644 --- a/extension/src/dxt.h +++ b/extension/src/dxt.h @@ -1,5 +1,5 @@ // dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp} -// (namespace engine -> mtgodot). DDS DXT1/3/5 + BGRA8 -> RGBA8, level 0. +// (namespace engine -> mtgodot). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0. // engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3 // 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。 // 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。 diff --git a/extension/src/metin2_anim.cpp b/extension/src/metin2_anim.cpp index a6933ba1..30b8be98 100644 --- a/extension/src/metin2_anim.cpp +++ b/extension/src/metin2_anim.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -25,10 +26,102 @@ using namespace godot; namespace mtgodot { +namespace { +struct ClipEntry { + String path; + uint64_t modified; + size_t bytes; + std::shared_ptr file; +}; +// Immutable decoded clips are shared, not playback time/pose/event cursors. +// Main-thread animation loading only; no Godot objects survive in this cache. +std::list clip_cache; +size_t clip_bytes = 0; +uint64_t clip_hits = 0, clip_misses = 0; +constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024; +struct MsaEntry { + String path; + uint64_t modified; + fmt::Msa metadata; +}; +std::list msa_cache; +uint64_t msa_hits = 0, msa_misses = 0; +// Keep events immutable in the cache; reload copies them into each actor. +bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) { + const uint64_t modified = FileAccess::get_modified_time(path); + for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) { + if (it->path != path) continue; + if (it->modified == modified) { + ++msa_hits; + metadata = it->metadata; + msa_cache.splice(msa_cache.begin(), msa_cache, it); + return true; + } + msa_cache.erase(it); + break; + } + ++msa_misses; + if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false; + if (msa_cache.size() >= 128) msa_cache.pop_back(); + msa_cache.push_front({path, modified, metadata}); + return true; +} +std::shared_ptr cached_clip(const String &path, gr2::LoadError *err) { + uint64_t modified = FileAccess::get_modified_time(path); + for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) { + if (it->path != path) continue; + if (it->modified == modified) { + ++clip_hits; + clip_cache.splice(clip_cache.begin(), clip_cache, it); + return clip_cache.front().file; + } + clip_bytes -= it->bytes; + clip_cache.erase(it); + break; + } + ++clip_misses; + auto loaded = gr2_from_file(path, err); + if (!loaded) return {}; + size_t bytes = 0; + for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size(); + auto file = std::make_shared(std::move(*loaded)); + if (bytes <= CLIP_BYTE_LIMIT) { + while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) { + clip_bytes -= clip_cache.back().bytes; + clip_cache.pop_back(); + } + clip_cache.push_front({path, modified, bytes, file}); + clip_bytes += bytes; + } + return file; +} +} + +Dictionary Metin2AnimPlayer::get_clip_cache_stats() { + Dictionary d; + d["hits"] = clip_hits; + d["misses"] = clip_misses; + d["entries"] = static_cast(clip_cache.size()); + d["section_bytes"] = static_cast(clip_bytes); + d["msa_hits"] = msa_hits; + d["msa_misses"] = msa_misses; + d["msa_entries"] = static_cast(msa_cache.size()); + return d; +} +void Metin2AnimPlayer::clear_clip_cache() { + clip_cache.clear(); + clip_bytes = 0; + clip_hits = clip_misses = 0; + msa_cache.clear(); + msa_hits = msa_misses = 0; +} + Metin2AnimPlayer::Metin2AnimPlayer() {} Metin2AnimPlayer::~Metin2AnimPlayer() = default; void Metin2AnimPlayer::_bind_methods() { + ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats); + ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache); ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path); ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path); ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path); @@ -49,6 +142,7 @@ void Metin2AnimPlayer::_bind_methods() { ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24)); ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation); ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events); + ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose); ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data); ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data); ClassDB::bind_static_method("Metin2AnimPlayer", @@ -63,6 +157,7 @@ void Metin2AnimPlayer::_bind_methods() { PropertyInfo(Variant::STRING, "sound"), PropertyInfo(Variant::VECTOR3, "pos"))); ADD_SIGNAL(MethodInfo("playback_finished")); + ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event"))); ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"), "set_anim_path", "get_anim_path"); @@ -132,17 +227,11 @@ static String strip_dpath(const String &raw) { return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2" } -String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) { +String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) { String low = spec.to_lower(); if (!low.ends_with(".msa")) { return spec; // already a .gr2 path } - fmt::Msa m; - std::string e; - if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) { - UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str())); - return spec; - } const String motion = String(m.motion_gr2.c_str()); const String base = motion.replace("\\", "/").get_file(); const String msa_dir = spec.get_base_dir(); @@ -171,16 +260,45 @@ String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) { return cand; } +static Dictionary motion_event_dict(const fmt::MotionEvent &ev) { + Dictionary d; + d["type"] = ev.type; + d["start_time"] = ev.start_time; + d["effect"] = String(ev.effect_file.c_str()); + d["sound"] = String(ev.sound_file.c_str()); + d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]); + d["bone"] = String(ev.attaching_bone.c_str()); + d["attaching"] = ev.attaching; + d["following"] = ev.following; + d["independent"] = ev.independent; + d["fishing_effect"] = ev.fishing_effect; + return d; +} + +Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) { + Dictionary result; + Metin2Model *model = resolve_model(); + const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr; + if (!sk || !anim_file) return result; + for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) { + if (String(sk->bones[i].name.c_str()) == bone) { + result["transform"] = gr2_to_godot(world_buf[i]); + break; + } + } + return result; +} + +void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) { + emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()), + String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2])); + emit_signal("motion_event_detailed", motion_event_dict(ev)); +} + Array Metin2AnimPlayer::get_events() const { Array a; for (const fmt::MotionEvent &ev : events) { - Dictionary d; - d["type"] = ev.type; - d["start_time"] = ev.start_time; - d["effect"] = String(ev.effect_file.c_str()); - d["sound"] = String(ev.sound_file.c_str()); - d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]); - a.push_back(d); + a.push_back(motion_event_dict(ev)); } return a; } @@ -280,7 +398,7 @@ void Metin2AnimPlayer::_ready() { } void Metin2AnimPlayer::reload() { - anim_file = std::nullopt; + anim_file.reset(); duration = 0.0; time = 0.0; prev_time = 0.0; @@ -294,16 +412,18 @@ void Metin2AnimPlayer::reload() { } // .msa -> resolve the real motion .gr2 + pull accumulation / events. + const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1"; String gr2_spec = anim_path; if (anim_path.to_lower().ends_with(".msa")) { fmt::Msa m; std::string e; - if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) { + if (cached_msa(anim_path, m, &e)) { msa_metadata = m; accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]); events = m.events; - gr2_spec = resolve_anim_gr2(anim_path); - UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s", + // Reuse the metadata just parsed; path resolution must not parse it again. + gr2_spec = resolve_anim_gr2(anim_path, m); + if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s", gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(), m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count, m.loop_start_time, m.loop_end_time) : String("none"))); @@ -314,7 +434,7 @@ void Metin2AnimPlayer::reload() { } gr2::LoadError err; - auto f = mtgodot::gr2_from_file(gr2_spec, &err); + auto f = cached_clip(gr2_spec, &err); if (!f) { UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") + String(err.stage.c_str()) + "]: " + String(err.message.c_str())); @@ -330,7 +450,7 @@ void Metin2AnimPlayer::reload() { duration = an.duration; // Diagnostic: how many anim tracks map to a model-skeleton bone by name? - if (Metin2Model *model = resolve_model()) { + if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) { if (const gr2::Skeleton *sk = model->gr2_skeleton()) { int matched = 0; godot::String unmatched; @@ -356,7 +476,7 @@ void Metin2AnimPlayer::reload() { last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d", String(an.name.c_str()), duration, (int)an.tracks.size(), (int)anim_file->file_info().animations.size()); - UtilityFunctions::print("[Metin2AnimPlayer] ", last_info); + if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info); apply_pose(0.0); } @@ -480,7 +600,7 @@ void Metin2AnimPlayer::_process(double delta) { if (prev_anim_file) { blend_elapsed += delta; // real seconds, independent of time_scale if (blend_elapsed >= blend_time) { - prev_anim_file = std::nullopt; + prev_anim_file.reset(); } } double next = time + delta * time_scale; @@ -508,9 +628,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) { double b = std::fmax(0.0, std::fmin(to, duration)); for (const fmt::MotionEvent &ev : events) { if (a < ev.start_time && ev.start_time <= b) { - emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()), - String(ev.sound_file.c_str()), - Vector3(ev.position[0], ev.position[1], ev.position[2])); + emit_motion_event(ev); } } return; @@ -522,9 +640,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) { double t = ev.start_time; bool hit = (a < t && t <= b) || (b > duration && t <= b - duration); if (hit) { - emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()), - String(ev.sound_file.c_str()), - Vector3(ev.position[0], ev.position[1], ev.position[2])); + emit_motion_event(ev); } } } diff --git a/extension/src/metin2_anim.h b/extension/src/metin2_anim.h index 0e77749c..63b499db 100644 --- a/extension/src/metin2_anim.h +++ b/extension/src/metin2_anim.h @@ -10,6 +10,7 @@ #include #include +#include #include namespace mtgodot { @@ -67,8 +68,12 @@ public: // .msa metadata (empty / zero when anim_path is a raw .gr2). godot::Vector3 get_accumulation() const { return accumulation; } godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...] + // Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending. + godot::Dictionary get_effect_bone_pose(const godot::String &bone); godot::Dictionary get_loop_data() const; godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...} + static godot::Dictionary get_clip_cache_stats(); + static void clear_clip_cache(); // CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py // (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns @@ -86,6 +91,7 @@ protected: static void _bind_methods(); private: + void emit_motion_event(const fmt::MotionEvent &ev); godot::String anim_path; godot::NodePath model_path; bool playing = true; @@ -95,14 +101,14 @@ private: double duration = 0.0; godot::String last_info; - std::optional anim_file; + std::shared_ptr anim_file; std::vector world_buf; std::vector skin_buf; // Crossfade state: the outgoing clip is kept and sampled at a frozen time, // its pose blended into the incoming clip for `blend_time` seconds. double blend_time = 0.15; - std::optional prev_anim_file; + std::shared_ptr prev_anim_file; double prev_anim_time = 0.0; double prev_anim_duration = 0.0; bool prev_anim_loop = true; @@ -119,7 +125,7 @@ private: void apply_pose(double t); void dispatch_events(double from, double to); // .msa path -> real motion .gr2 path; passes plain .gr2 paths through. - static godot::String resolve_anim_gr2(const godot::String &spec); + static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata); }; } // namespace mtgodot diff --git a/extension/src/metin2_model.cpp b/extension/src/metin2_model.cpp index f55cf55e..28fb6cfd 100644 --- a/extension/src/metin2_model.cpp +++ b/extension/src/metin2_model.cpp @@ -69,6 +69,7 @@ void Metin2Model::_bind_methods() { ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload); ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info); ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb); + ClassDB::bind_method(D_METHOD("get_fly_target_bounds"), &Metin2Model::get_fly_target_bounds); ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset); ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options); ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2); @@ -274,14 +275,26 @@ void Metin2Model::_set_lod(int n) { lod_prev_mi->set_name("LodGhost"); lod_prev_mi->set_layer_mask(1u << 1); lod_prev_mi->set_mesh(mi->get_mesh()); + lod_prev_mi->set_custom_aabb(mi->get_custom_aabb()); add_child(lod_prev_mi); lod_prev_mi->set_transform(mi->get_transform()); std::vector> ghost_mats; - for (int s = 0; s < (int)surf_mats.size(); ++s) { - Ref src = surf_mats[s]; + // A frozen GPU ghost must not share the live, updated bone texture. + Ref ghost_bones; + if (gpu_skin_active && bones_img.is_valid()) { + Ref snapshot = bones_img->duplicate(); + ghost_bones = ImageTexture::create_from_image(snapshot); + } + for (int s = 0; s < mi->get_mesh()->get_surface_count(); ++s) { + // CPU hair overrides are not necessarily present in surf_mats. + Ref src = mi->get_active_material(s); Ref dup; if (src.is_valid()) { dup = src->duplicate(); + Ref shader = dup; + if (shader.is_valid() && ghost_bones.is_valid()) { + shader->set_shader_parameter("bones_tex", ghost_bones); + } } lod_prev_mi->set_surface_override_material(s, dup); ghost_mats.push_back(dup); @@ -298,7 +311,11 @@ void Metin2Model::_set_lod(int n) { _end_lod_fade(); return; } - if (cpu_mesh.is_valid()) { + if (gpu_skin_active) { + // Keep attachments and the body/hair material boundary across LODs. + _build_gpu_mesh(); + gpu_visual_bounds_ready = false; + } else if (cpu_mesh.is_valid()) { AABB b; cpu_mesh = build_mesh(*afi, parts, flip_winding, b); mi->set_mesh(cpu_mesh); @@ -310,6 +327,9 @@ void Metin2Model::_set_lod(int n) { } } _apply_materials(); + if (gpu_skin_active && !current_skin.empty()) { + gpu_skin(current_skin); + } if (cpu_mesh.is_valid() && !current_skin.empty()) { cpu_skin(current_skin); } @@ -372,6 +392,8 @@ void Metin2Model::_clear_children() { } void Metin2Model::reload() { + current_world_pose.clear(); + fly_target_bounds_valid = false; _clear_children(); *file = std::nullopt; last_info = ""; @@ -595,24 +617,37 @@ void Metin2Model::_load_hair() { UtilityFunctions::print(vformat("[Metin2Model] hair: %s %d/%d bones matched, %d parts", hair_gr2.get_file(), matched, (int)hbones.size(), (int)hair_parts.size())); - if (has_cpu_skin_mesh() && !current_skin.empty()) +} + +void Metin2Model::_refresh_hair_mesh() { + if (gpu_skin_active) { + _build_gpu_mesh(); + _apply_materials(); + gpu_visual_bounds_ready = false; + if (!current_skin.empty()) gpu_skin(current_skin); + } else if (has_cpu_skin_mesh() && !current_skin.empty()) { cpu_skin(current_skin); + } } void Metin2Model::set_hair_gr2(const String &p) { if (p == hair_gr2) return; hair_gr2 = p; - if (file && *file) + if (file && *file) { _load_hair(); + _refresh_hair_mesh(); + } } void Metin2Model::set_hair_skin(const String &p) { if (p == hair_skin) return; hair_skin = p; - if (file && *file && !hair_gr2.is_empty()) + if (file && *file && !hair_gr2.is_empty()) { _load_hair(); + _refresh_hair_mesh(); + } } void Metin2Model::set_weapon_gr2(const String &p) { @@ -793,6 +828,7 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name, } void Metin2Model::update_weapon_pose(const std::vector &world_pose) { + current_world_pose = world_pose; // weapon local = gripPre · handWorld (client: weaponBone[0] composed onto the // parent attach-bone matrix). gripPre is identity for near-origin weapons, so // this is backward-compatible with the ones that already looked right. @@ -804,6 +840,38 @@ void Metin2Model::update_weapon_pose(const std::vector &world_pose) { mtgodot::mul4x3(shield_pre, world_pose[shield_bone_idx]))); } +namespace { inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o); } + +Dictionary Metin2Model::get_fly_target_bounds() { + // Legacy caches its sphere until the model is reset. Do not let particles, + // hair, weapons, LOD ghosts or subsequent animation frames shift the centre. + if (!fly_target_bounds_valid) { + const gr2::FileInfo *fi = active_fi(); + bool found = false; + AABB bounds; + if (fi) for (const auto &mesh : fi->meshes) { + for (size_t slot = 0; slot < mesh.bone_bounds.size(); ++slot) { + const auto &obb = mesh.bone_bounds[slot]; + const int bone = slot < mesh.bone_bindings.size() ? mesh.bone_bindings[slot] : -1; + if (!obb.valid || bone < 0 || bone >= (int)current_world_pose.size()) continue; + for (int corner = 0; corner < 8; ++corner) { + float point[3], transformed[3]; + for (int axis = 0; axis < 3; ++axis) point[axis] = (corner & (1 << axis)) ? obb.max[axis] : obb.min[axis]; + xform_pt(current_world_pose[bone], point, transformed); + const Vector3 p(transformed[0], transformed[1], transformed[2]); + bounds = found ? bounds.expand(p) : AABB(p, Vector3()); + found = true; + } + } + } + if (found) { fly_target_bounds = bounds; fly_target_bounds_valid = true; } + } + Dictionary result; + result["valid"] = fly_target_bounds_valid; + result["bounds"] = fly_target_bounds; + return result; +} + String Metin2Model::_guess_texture_dir() const { if (!texture_dir.is_empty()) { return texture_dir; @@ -1137,6 +1205,7 @@ void Metin2Model::_apply_materials() { md.albedo = hair_tex; md.blend = mtgodot::BlendMode::AlphaTest; md.alpha_scissor = 0.5f; + md.two_sided = true; // Match CPU hair cards when viewed from behind. md.skinned = true; md.bones_tex = bones_tex; Ref hm = mtgodot::make_material(md); @@ -1591,10 +1660,11 @@ void Metin2Model::gpu_skin(const std::vector &skin) { return; } current_skin = skin; - // The shader owns deformation after this point. Sample the posed bounds - // once for culling and initial camera framing; doing the full vertex walk on - // every GPU frame would erase the CPU saving this path is intended to make. - if (!gpu_visual_bounds_ready && _update_skinned_bounds(skin)) { + // Culling must follow every pose, including offscreen actors and one-shots. + // Only the initial ready notification reframes selection cameras; emitting + // it on every pose would make the camera follow the breathing animation. + const bool bounds_updated = _update_skinned_bounds(skin); + if (!gpu_visual_bounds_ready && bounds_updated) { gpu_visual_bounds_ready = true; emit_signal("visual_bounds_changed"); } @@ -1620,12 +1690,7 @@ String Metin2Model::get_info() const { } AABB Metin2Model::get_visual_aabb() { - // Consumers such as CharSelect ask only while framing. Refresh on demand - // so their final camera fit reflects the current GPU pose without adding a - // per-frame CPU vertex walk to every world actor. - if (gpu_skin_active && !current_skin.empty()) { - _update_skinned_bounds(current_skin); - } + // Both animation paths publish bounds when sampling, before camera queries. if (have_visual_bounds) { return visual_bounds; } diff --git a/extension/src/metin2_model.h b/extension/src/metin2_model.h index 6a69c689..1834fa4c 100644 --- a/extension/src/metin2_model.h +++ b/extension/src/metin2_model.h @@ -130,6 +130,7 @@ public: // Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike // MeshInstance3D::get_aabb(), this follows the GPU shader pose as well. godot::AABB get_visual_aabb(); + godot::Dictionary get_fly_target_bounds(); // Parent-space Y adjustment that places the bind-pose model's lowest point // on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres. double get_ground_offset() const { return ground_offset; } @@ -187,6 +188,7 @@ private: godot::Ref hair_tex; // resolved hair albedo (for GPU path) int gpu_base_surf = 0; // base surfaces before appended GPU hair void _load_hair(); + void _refresh_hair_mesh(); void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2) godot::String weapon_gr2; // attached weapon .gr2 (or "") @@ -244,6 +246,9 @@ private: bool gpu_visual_bounds_ready = false; bool have_visual_bounds = false; godot::AABB visual_bounds; + std::vector current_world_pose; + godot::AABB fly_target_bounds; + bool fly_target_bounds_valid = false; std::vector current_skin; godot::Ref bones_img; // RGBAF 3 x bone_count godot::Ref bones_tex; diff --git a/extension/src/metin2_world.cpp b/extension/src/metin2_world.cpp index 54238d8f..041d82d2 100644 --- a/extension/src/metin2_world.cpp +++ b/extension/src/metin2_world.cpp @@ -611,12 +611,13 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, } } - // Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。 - // 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。 + // Native extracted tree geometry, shared once per treefile across instances. for (auto &kv : tree_groups) { if (kv.second.xforms.empty()) continue; - Ref tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f); + Ref tmesh = get_tree_mesh(kv.first, *resolver, 12.0f); + const bool native = String(tmesh->get_meta("tree_geometry", "proxy")) == "native_spt"; + (native ? native_trees_placed : proxy_trees_placed) += (int)kv.second.xforms.size(); Ref mm; mm.instantiate(); mm->set_transform_format(MultiMesh::TRANSFORM_3D); @@ -628,6 +629,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename()); mmi->set_layer_mask(1u << 0); mmi->set_multimesh(mm); + mmi->set_meta("tree_geometry", native ? "native_spt" : "proxy"); // §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。 mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON : GeometryInstance3D::SHADOW_CASTING_SETTING_OFF); @@ -827,6 +829,7 @@ void Metin2World::set_focus_position(double gx_m, double gz_m) { } void Metin2World::_process(double) { + update_water_animation(); for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) { auto [tx, ty] = stream_queue.front(); stream_queue.erase(stream_queue.begin()); @@ -854,6 +857,7 @@ void Metin2World::unload_map() { chunks_built = chunks_failed = chunks_splatted = 0; objects_placed = objects_skipped = objects_missing_model = 0; trees_placed = tree_species = 0; + native_trees_placed = proxy_trees_placed = 0; objects_mdatr_built = 0; build_ms = 0; splat_ready = registry_ok = false; @@ -997,6 +1001,8 @@ Dictionary Metin2World::get_load_report() const { d["ambience_sources"] = (int)ambience_sources.size(); d["trees_placed"] = trees_placed; d["tree_species"] = tree_species; + d["native_trees_placed"] = native_trees_placed; + d["proxy_trees_placed"] = proxy_trees_placed; d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .mdatr 精确碰撞的对象数 d["static_meshes"] = static_cache.loaded; d["build_ms"] = build_ms; diff --git a/extension/src/metin2_world.h b/extension/src/metin2_world.h index c8d6400e..7219c7b6 100644 --- a/extension/src/metin2_world.h +++ b/extension/src/metin2_world.h @@ -143,6 +143,7 @@ private: int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0; int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0; int trees_placed = 0, tree_species = 0; + int native_trees_placed = 0, proxy_trees_placed = 0; int objects_mdatr_built = 0; godot::Node3D *objects_root = nullptr; double build_ms = 0; diff --git a/extension/src/register_types.cpp b/extension/src/register_types.cpp index 646268f2..ae73828a 100644 --- a/extension/src/register_types.cpp +++ b/extension/src/register_types.cpp @@ -48,6 +48,7 @@ void initialize_mtgodot_module(ModuleInitializationLevel p_level) { void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) { if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) { fmt::set_file_reader(nullptr); + mtgodot::Metin2AnimPlayer::clear_clip_cache(); mtgodot::cleanup_material_shaders(); mtgodot::cleanup_terrain_shader(); mtgodot::cleanup_tree_shader(); diff --git a/extension/src/tree_placeholder.cpp b/extension/src/tree_placeholder.cpp index 569b017a..94951993 100644 --- a/extension/src/tree_placeholder.cpp +++ b/extension/src/tree_placeholder.cpp @@ -9,6 +9,13 @@ #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -477,15 +484,57 @@ Ref build_placeholder_tree(const String &species_hint, float height_m return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty); } -Ref get_tree_proxy_mesh(const std::string &treefile, +static Ref load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) { + const String source(resolver.resolve(treefile, nullptr).c_str()); + if (source.is_empty()) return {}; + const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry"); + const String manifest_path = directory.path_join("manifest.json"); + if (!FileAccess::file_exists(manifest_path)) return {}; + const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path)); + if (parsed.get_type() != Variant::DICTIONARY) return {}; + const Dictionary manifest = parsed; + if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {}; + const Dictionary trees = manifest.get("trees", Dictionary()); + const String source_hash = FileAccess::get_sha256(source); + if (!trees.has(source_hash)) return {}; + const Dictionary record = trees[source_hash]; + const String filename = record.get("glb", ""); + if (filename != source_hash + String(".glb")) return {}; + const String path = directory.path_join(filename); + if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {}; + Ref document; + document.instantiate(); + Ref state; + state.instantiate(); + if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {}; + Ref gltf_mesh = state->get_meshes()[0]; + if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {}; + Ref mesh = gltf_mesh->get_mesh()->get_mesh(); + if (mesh.is_null() || mesh->get_surface_count() == 0) return {}; + mesh->set_meta("tree_geometry", "native_spt"); + mesh->set_meta("source_sha256", source_hash); + mesh->set_meta("glb_sha256", record.get("glb_sha256", "")); + mesh->set_meta("leaf_mode", "static_extracted_cards"); + // GLB vertices already use Godot metres. Never impose the proxy's 12m height. + return mesh; +} + +Ref get_tree_mesh(const std::string &treefile, const fmt::AssetResolver &resolver, float height_m) { const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) + "#" + std::to_string(height_m); auto found = g_tree_mesh_cache.find(key); if (found != g_tree_mesh_cache.end()) return found->second; + Ref native = load_native_tree(treefile, resolver); + if (native.is_valid()) { + g_tree_mesh_cache.emplace(key, native); + return native; + } + UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str())); const TreeTextures textures = resolve_tree_textures(treefile, resolver); Ref mesh = build_proxy_impl(treefile, height_m, textures); + mesh->set_meta("tree_geometry", "proxy"); g_tree_mesh_cache.emplace(key, mesh); return mesh; } diff --git a/extension/src/tree_placeholder.h b/extension/src/tree_placeholder.h index 8b1201aa..ca3bb1d5 100644 --- a/extension/src/tree_placeholder.h +++ b/extension/src/tree_placeholder.h @@ -10,8 +10,8 @@ namespace fmt { struct AssetResolver; } -// W4/R2 —— `.spt` 几何尚未跨平台读取(见 formats/spt.h),运行时使用 tree proxy: -// 确定性枝干 + 多组交叉叶簇,并优先采用 .spt 指向的真实 bark/composite DDS。 +// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary +// runtime on macOS. Deterministic procedural geometry remains a reported fallback. // 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。 namespace mtgodot { @@ -19,10 +19,11 @@ namespace mtgodot { godot::Ref build_placeholder_tree(const godot::String &species_hint, float height_m = 12.0f); -// R2 tree proxy:保留无专有运行时的限制,但从 .spt 嗅探真实树皮和 composite atlas, -// 用确定性的枝干/叶簇 mesh 近似 SpeedTree 的 branch/frond/leaf 分层。结果按 treefile 缓存, -// 可安全用于每树种一个 MultiMesh。若资源解析失败,自动退回纯程序化材质。 -godot::Ref get_tree_proxy_mesh(const std::string &treefile, +// Cached per treefile for MultiMesh use. Missing or mismatched native resources +// use a proxy with explicit metadata and a warning; package acceptance rejects it. +// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural +// implementation is an explicitly reported fallback for missing/invalid trees. +godot::Ref get_tree_mesh(const std::string &treefile, const fmt::AssetResolver &resolver, float height_m = 12.0f); // 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。 diff --git a/extension/src/water_builder.cpp b/extension/src/water_builder.cpp index 71ad8f76..70672a3f 100644 --- a/extension/src/water_builder.cpp +++ b/extension/src/water_builder.cpp @@ -1,4 +1,5 @@ #include "water_builder.h" +#include "water_motion.h" #include "asset_io.h" #include "dxt.h" @@ -7,6 +8,8 @@ #include #include #include +#include +#include #include #include #include @@ -29,32 +32,30 @@ namespace { // 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。 // UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。 const char *SRC_WATER = R"(shader_type spatial; -render_mode blend_mix, cull_disabled, depth_draw_never, diffuse_lambert, specular_schlick_ggx; +render_mode blend_mix, cull_disabled, depth_draw_never, unshaded; uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable; -uniform vec3 tint : source_color = vec3(0.10, 0.22, 0.26); uniform float uv_per_meter = 0.125; // 1/8m,= 原客户端 1/(CELLSCALE*4) -uniform float bob_amp = 0.06; // 高度浮动幅度(米),近似 MapOutdoorWater 0..-15cm +uniform float height_offset = 0.0; // Shared reference water translation, metres. void vertex() { - VERTEX.y += sin(TIME * 0.6 + VERTEX.x * 0.01 + VERTEX.z * 0.013) * bob_amp; + VERTEX.y += height_offset; } void fragment() { int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧 vec2 uv = UV * uv_per_meter; vec3 tex = texture(frames, vec3(uv, float(f))).rgb; - float fres = pow(1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0), 3.0); - ALBEDO = mix(tint, tex, 0.6) + fres * 0.15; - ALPHA = clamp(COLOR.a + fres * 0.25, 0.12, 0.95); - ROUGHNESS = 0.08; - METALLIC = 0.0; - SPECULAR = 0.6; + // MapOutdoorWater selects texture RGB and vertex diffuse alpha directly. + // Do not introduce PBR lighting, invented tint or view-dependent opacity. + ALBEDO = tex; + ALPHA = COLOR.a; } )"; Ref g_water_shader; Ref g_water_mat; // 30 帧数组只建一次 +WaterMotion g_water_motion; Ref water_material(const fmt::AssetResolver &res) { if (g_water_mat.is_valid()) @@ -107,6 +108,19 @@ Ref water_material(const fmt::AssetResolver &res) { void cleanup_water_shader() { g_water_shader.unref(); g_water_mat.unref(); + g_water_motion = WaterMotion{}; +} + +void update_water_animation() { + if (g_water_mat.is_null()) return; + const uint64_t now = Time::get_singleton()->get_ticks_msec(); + uint64_t duration = 1000; + double depth = 0; + if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) { + duration = UtilityFunctions::randi_range(1000, 3000); + if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15); + } + g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth)); } std::vector build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm, @@ -126,8 +140,9 @@ std::vector build_chunk_water(const fmt::WaterMap &wm, const fmt::He double ly = tex_y * double(fmt::m2coord::CELLSCALE); double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm double depth_cm = water_h_cm - th; - float a = float(depth_cm / 60.0); // 60cm 深 -> 接近不透明 - return a < 0.12f ? 0.12f : (a > 0.9f ? 0.9f : a); + // AreaTerrain uses RAW height differences, before HeightScale, with + // OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor). + return water_depth_alpha(depth_cm, height_scale); }; for (int layer = 0; layer < wm.layer_count; ++layer) { @@ -147,8 +162,14 @@ std::vector build_chunk_water(const fmt::WaterMap &wm, const fmt::He continue; } int xs = x; - while (x < W && wm.ids[y * W + x] == layer) - ++x; + // Keep each cell's four shoreline samples. Merging a whole row + // discarded interior terrain peaks and bridged dry banks with water. + ++x; + float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm); + float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm); + // AreaTerrain skips the quad when every diffuse alpha is zero. + if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0) + continue; double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M; double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M; int base = v.size(); @@ -162,8 +183,6 @@ std::vector build_chunk_water(const fmt::WaterMap &wm, const fmt::He uv.push_back(Vector2(float(x1), float(z0))); uv.push_back(Vector2(float(x1), float(z1))); uv.push_back(Vector2(float(x0), float(z1))); - float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm); - float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm); col.push_back(Color(1, 1, 1, aLL)); col.push_back(Color(1, 1, 1, aLR)); col.push_back(Color(1, 1, 1, aUR)); diff --git a/extension/src/water_builder.h b/extension/src/water_builder.h index 889ba71c..3bf8f1d8 100644 --- a/extension/src/water_builder.h +++ b/extension/src/water_builder.h @@ -1,4 +1,5 @@ #pragma once +#include "water_depth.h" #include #include @@ -16,6 +17,7 @@ struct AssetResolver; // 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。 namespace mtgodot { + struct WaterPiece { godot::Ref mesh; // Godot 空间,已含区块原点 float godot_y = 0; @@ -26,5 +28,6 @@ std::vector build_chunk_water(const fmt::WaterMap &wm, const fmt::He int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res); void cleanup_water_shader(); +void update_water_animation(); } // namespace mtgodot diff --git a/extension/src/water_depth.h b/extension/src/water_depth.h new file mode 100644 index 00000000..152e2770 --- /dev/null +++ b/extension/src/water_depth.h @@ -0,0 +1,12 @@ +#pragma once +#include + +namespace mtgodot { +// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres. +inline float water_depth_alpha(double depth_cm, double height_scale) { + if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) || + height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f; + double alpha = depth_cm / (400.0 * height_scale); + return static_cast(alpha > 0.8 ? 0.8 : alpha); +} +} diff --git a/extension/src/water_motion.h b/extension/src/water_motion.h new file mode 100644 index 00000000..7d4b84da --- /dev/null +++ b/extension/src/water_motion.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +namespace mtgodot { +// MapOutdoorWater: one shared vertical translation, alternating a random +// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms. +struct WaterMotion { + uint64_t start_ms = 0, duration_ms = 300; + double begin = 0, end = 0, current = 0; + bool initialized = false; + double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) { + if (!initialized) { start_ms = now; initialized = true; } + if (now - start_ms > duration_ms) { + begin = current; + end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0; + start_ms = now; + duration_ms = std::clamp(next_duration, 1000, 3000); + } + current = begin + (end - begin) * double(now - start_ms) / double(duration_ms); + return current; + } +}; +} diff --git a/extension/tests/dxt_decode_test.cpp b/extension/tests/dxt_decode_test.cpp new file mode 100644 index 00000000..d71e57e4 --- /dev/null +++ b/extension/tests/dxt_decode_test.cpp @@ -0,0 +1,51 @@ +#include "dxt.h" +#include +#include +#include +using Bytes = std::vector; +void put(Bytes& b, size_t off, uint32_t v) { + for (int i = 0; i < 4; ++i) b[off + i] = uint8_t(v >> (8 * i)); +} +Bytes header(uint32_t bits, uint32_t r, uint32_t g, uint32_t b, uint32_t a, size_t payload = 8) { + Bytes out(128 + payload, 0); + put(out, 0, 0x20534444); put(out, 4, 124); put(out, 12, 1); put(out, 16, 2); + put(out, 76, 32); put(out, 80, 0x40 | (a ? 1 : 0)); put(out, 88, bits); + put(out, 92, r); put(out, 96, g); put(out, 100, b); put(out, 104, a); + return out; +} +mtgodot::Image decode(const Bytes& b) { return mtgodot::load_dds(b.data(), b.size()); } +void pixel(const mtgodot::Image& im, size_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t a) { + assert(im.ok()); assert(im.rgba[n*4] == r && im.rgba[n*4+1] == g && im.rgba[n*4+2] == b && im.rgba[n*4+3] == a); +} +int main() { + auto a1555 = header(16, 0x7c00, 0x3e0, 0x1f, 0x8000); + put(a1555, 128, 0x001ffc00); // opaque red, transparent blue + pixel(decode(a1555), 0, 255, 0, 0, 255); pixel(decode(a1555), 1, 0, 0, 255, 0); + auto rgb565 = header(16, 0xf800, 0x7e0, 0x1f, 0); + put(rgb565, 128, 0x001f07e0); + pixel(decode(rgb565), 0, 0, 255, 0, 255); pixel(decode(rgb565), 1, 0, 0, 255, 255); + auto a4444 = header(16, 0xf00, 0xf0, 0xf, 0xf000); + put(a4444, 128, 0x12348f00); + pixel(decode(a4444), 0, 255, 0, 0, 136); pixel(decode(a4444), 1, 34, 51, 68, 17); + auto padded = header(24, 0xff0000, 0xff00, 0xff, 0, 8); + put(padded, 16, 1); put(padded, 12, 2); put(padded, 8, 8); put(padded, 20, 4); + put(padded, 128, 0xaaff0000); put(padded, 132, 0xbb0000ff); + pixel(decode(padded), 0, 255, 0, 0, 255); pixel(decode(padded), 1, 0, 0, 255, 255); + auto rgba = header(32, 0xff, 0xff00, 0xff0000, 0xff000000); + put(rgba, 128, 0x80402010); pixel(decode(rgba), 0, 16, 32, 64, 128); + put(rgba, 80, 0x40); pixel(decode(rgba), 0, 16, 32, 64, 255); // XRGB ignores unused alpha bits + auto invalid = a1555; invalid.resize(130); assert(!decode(invalid).ok()); + invalid = padded; put(invalid, 20, 2); assert(!decode(invalid).ok()); + invalid = a1555; put(invalid, 92, 0x3e0); assert(!decode(invalid).ok()); + invalid = a1555; put(invalid, 92, 0x7400); assert(!decode(invalid).ok()); + for (uint32_t fourcc : {0x31545844u, 0x33545844u, 0x35545844u}) { + auto dxt = header(0, 0, 0, 0, 0, 16); + put(dxt, 12, 4); put(dxt, 16, 4); put(dxt, 80, 4); put(dxt, 84, fourcc); + const bool dxt1 = fourcc == 0x31545844; + put(dxt, dxt1 ? 128 : 136, 0x0000f800); + if (fourcc == 0x33545844) { put(dxt, 128, 0xffffffff); put(dxt, 132, 0xffffffff); } + if (fourcc == 0x35545844) dxt[128] = 255; + pixel(decode(dxt), 0, 255, 0, 0, 255); + } + std::puts("PASS: dxt_decode_test (masked RGB, pitch, invalid headers, DXT1/3/5)"); +} diff --git a/extension/tests/water_depth_test.cpp b/extension/tests/water_depth_test.cpp new file mode 100644 index 00000000..1dc524d1 --- /dev/null +++ b/extension/tests/water_depth_test.cpp @@ -0,0 +1,17 @@ +#include "water_depth.h" +#include +#include +#include +#include +int main() { + for (double scale : {0.25, 0.5, 1.0, 2.0}) { + assert(mtgodot::water_depth_alpha(-1, scale) == 0); + assert(mtgodot::water_depth_alpha(0, scale) == 0); + assert(std::abs(mtgodot::water_depth_alpha(100 * scale, scale) - 0.25) < 1e-6); + assert(std::abs(mtgodot::water_depth_alpha(320 * scale, scale) - 0.8) < 1e-6); + assert(std::abs(mtgodot::water_depth_alpha(1000 * scale, scale) - 0.8) < 1e-6); + } + assert(mtgodot::water_depth_alpha(100, 0) == 0); + assert(mtgodot::water_depth_alpha(std::numeric_limits::quiet_NaN(), 1) == 0); + std::puts("PASS: water depth reference (raw depth, shoreline, cap, height scale)"); +} diff --git a/extension/tests/water_motion_test.cpp b/extension/tests/water_motion_test.cpp new file mode 100644 index 00000000..f88b5e37 --- /dev/null +++ b/extension/tests/water_motion_test.cpp @@ -0,0 +1,21 @@ +#include "water_motion.h" +#include +#include +#include +int main() { + mtgodot::WaterMotion m; + assert(m.sample(100, 2000, 15) == 0); + assert(m.sample(400, 2000, 15) == 0); + assert(m.sample(401, 2000, 15) == 0); + assert(std::abs(m.sample(1401, 2000, 15) + .075) < 1e-9); + assert(std::abs(m.sample(2401, 2000, 15) + .15) < 1e-9); + assert(std::abs(m.sample(2402, 1000, 15) + .15) < 1e-9); + assert(std::abs(m.sample(2902, 1000, 15) + .075) < 1e-9); + assert(m.sample(3402, 1000, 15) == 0); + // Large frame gaps start at the last rendered height, without overshoot. + for (uint64_t t = 3403; t < 200000; t += 137) { + const double y = m.sample(t, 1700, 9); + assert(y <= 0 && y >= -.15); + } + std::puts("PASS: shared linear water motion, endpoints, timing, no overshoot"); +} diff --git a/formats/msa.cpp b/formats/msa.cpp index f89b1f2a..37ed6344 100644 --- a/formats/msa.cpp +++ b/formats/msa.cpp @@ -104,6 +104,11 @@ bool parse_msa(const std::string& text, Msa& out, std::string* err) { e.start_time = ev.num("StartingTime"); e.effect_file = ev.str("EffectFileName"); e.sound_file = ev.str("SoundFileName"); + e.attaching_bone = ev.str("AttachingBoneName"); + e.attaching = ev.inum("AttachingEnable") != 0; + e.following = ev.inum("FollowingEnable") != 0; + e.independent = ev.inum("IndependentFlag") != 0; + e.fishing_effect = ev.inum("FishingEffectFlag") != 0; vec3(ev.find("EffectPosition"), e.position); out.events.push_back(std::move(e)); } @@ -130,6 +135,11 @@ bool parse_msa_file(const std::string& path, Msa& out, std::string* err) { e.start_time = ev.num("StartingTime"); e.effect_file = ev.str("EffectFileName"); e.sound_file = ev.str("SoundFileName"); + e.attaching_bone = ev.str("AttachingBoneName"); + e.attaching = ev.inum("AttachingEnable") != 0; + e.following = ev.inum("FollowingEnable") != 0; + e.independent = ev.inum("IndependentFlag") != 0; + e.fishing_effect = ev.inum("FishingEffectFlag") != 0; vec3(ev.find("EffectPosition"), e.position); out.events.push_back(std::move(e)); } diff --git a/formats/msa.h b/formats/msa.h index 5bf28485..6462bfb5 100644 --- a/formats/msa.h +++ b/formats/msa.h @@ -10,6 +10,11 @@ struct MotionEvent { float start_time = 0; // StartingTime std::string effect_file; // EffectFileName(type 10) std::string sound_file; // SoundFileName + std::string attaching_bone; + bool attaching = false; + bool following = false; + bool independent = false; + bool fishing_effect = false; float position[3] = {0, 0, 0}; }; diff --git a/formats/spt.h b/formats/spt.h index b8039ab0..2d1536d3 100644 --- a/formats/spt.h +++ b/formats/spt.h @@ -4,6 +4,9 @@ // 里(`m2dev-client-src-main/extern/library/SpeedTree/speedtree_static.lib`,实测 COFF x86-64, // IDV 专有,不能链进 macOS/移动端、不可随意分发);`SpeedTreeLib/` 是渲染 wrapper,不含 parser。 // → 运行时使用读取真实 bark/composite atlas 的 proxy;真几何走经授权的 Windows x64 离线 exporter。 +// 2026-09-11: hash-matched native exports for 87 local species are now staged in +// assets/TreeGeometry and loaded preferentially by the runtime. This sniffer +// remains only the fallback texture reader, not a geometry decoder. // // 这里只做「嗅探」:读文件头 magic + version,抽取内嵌贴图和 composite atlas token。 // 不解析枝干/叶片几何。无 godot 依赖。 diff --git a/formats/tests/msa_test.cpp b/formats/tests/msa_test.cpp index d7cf785b..64497ebd 100644 --- a/formats/tests/msa_test.cpp +++ b/formats/tests/msa_test.cpp @@ -5,6 +5,32 @@ #include int main() { + { + fmt::Msa effects; + std::string error; + if (!fmt::parse_msa(R"(MotionFileName "test.gr2" +Group MotionEventData +{ + Group Event00 + { + MotionEventType 1 + StartingTime 0.25 + EffectFileName "d:/ymir work/pc/test.mse" + AttachingBoneName "equip_right_hand" + AttachingEnable 1 + FollowingEnable 1 + IndependentFlag 1 + EffectPosition 10 20 30 + } +} +)", effects, &error) || effects.events.size() != 1 || + !effects.events[0].attaching || !effects.events[0].following || + !effects.events[0].independent || effects.events[0].attaching_bone != "equip_right_hand" || + effects.events[0].position[2] != 30) { + std::fprintf(stderr, "effect attachment metadata lost\n"); + return 1; + } + } const std::string text = R"( ScriptType MotionData MotionFileName "d:/ymir work/pc/test.gr2" diff --git a/libgr2/include/gr2/types.h b/libgr2/include/gr2/types.h index cd4c1b1e..c4dea162 100644 --- a/libgr2/include/gr2/types.h +++ b/libgr2/include/gr2/types.h @@ -45,12 +45,17 @@ struct TriGroup { }; struct Mesh { + struct BoneBounds { + float min[3] = {}, max[3] = {}; + bool valid = false; + }; std::string name; VertexKind kind = VertexKind::Unknown; std::vector vertices; std::vector indices; // 三角形列表 std::vector tri_groups; std::vector bone_bindings; // mesh 局部骨骼槽 → skeleton 骨骼索引 + std::vector bone_bounds; // Parallel source BoneBindings OBBMin/OBBMax. uint32_t source_bone_weight_slots = 0; // 顶点类型声明值;审计 >4/8 权重 uint32_t source_bone_index_slots = 0; // packed UInt32 记为 4 // 与 granny_mesh.MaterialBindings 平行:每项 = 解析出的漫反射贴图文件名 diff --git a/libgr2/src/gr2_mesh.cpp b/libgr2/src/gr2_mesh.cpp index c173197d..340e2d6b 100644 --- a/libgr2/src/gr2_mesh.cpp +++ b/libgr2/src/gr2_mesh.cpp @@ -237,10 +237,23 @@ bool File::Impl::extract_mesh(Ref mesh_obj, Ref mesh_type, if (read_ref_to_array(mesh_obj, *mBB, bb, bc, bs) && bc) { const auto& bbT = parse_type(mBB->ref_type); const TypeMember* mBN = find_member(bbT, "BoneName"); + const TypeMember* mMin = find_member(bbT, "OBBMin"); + const TypeMember* mMax = find_member(bbT, "OBBMax"); + out.bone_bounds.resize(bc); std::vector names(bc); for (uint32_t i = 0; i < bc; ++i) { Ref e = {bb.section, bb.offset + i * bs}; names[i] = mBN ? rd_str(member_slot(e, *mBN)) : std::string(); + auto &bounds = out.bone_bounds[i]; + if (mMin && mMax && mMin->type == detail::MT_Real32 && mMax->type == detail::MT_Real32 && mMin->array_width == 3 && mMax->array_width == 3) { + Ref lo = member_slot(e, *mMin), hi = member_slot(e, *mMax); + bounds.valid = true; + for (int axis = 0; axis < 3; ++axis) { + bounds.min[axis] = rd_f32({lo.section, lo.offset + uint32_t(axis * 4)}); + bounds.max[axis] = rd_f32({hi.section, hi.offset + uint32_t(axis * 4)}); + bounds.valid &= std::isfinite(bounds.min[axis]) && std::isfinite(bounds.max[axis]) && bounds.min[axis] <= bounds.max[axis]; + } + } } std::vector best(bc, -1); int best_dangling = int(bc) + 1; diff --git a/project/animation_cache_test.gd b/project/animation_cache_test.gd new file mode 100644 index 00000000..81eaabe7 --- /dev/null +++ b/project/animation_cache_test.gd @@ -0,0 +1,40 @@ +extends SceneTree +const PlayerView = preload("res://ui/player_view.gd") +var failures := 0 +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + message) +func _init() -> void: + call_deferred("run") +func run() -> void: + Metin2AnimPlayer.clear_clip_cache() + var a := PlayerView.new() + var b := PlayerView.new() + for view in [a, b]: + check(view.build(AssetRoot.path(), 0), "warrior builds") + root.add_child(view) + view.anim.set_process(false) + check(Metin2AnimPlayer.get_clip_cache_stats().misses == 1, "two actors decode idle only once") + a.anim.set_time(0.3) + b.anim.set_time(0.9) + check(not is_equal_approx(a.anim.get_time(), b.anim.get_time()), "playback clocks remain independent") + for i in 3: + for state in ["run", "attack", "wait"]: + for view in [a, b]: + view.set_anim_state(state) + view.anim.set_time(0.2) + var stats: Dictionary = Metin2AnimPlayer.get_clip_cache_stats() + check(stats.misses == 3 and stats.hits >= 17, "hot motion switches reuse decoded clips") + check(stats.entries <= 32 and stats.section_bytes <= 64 * 1024 * 1024, "cache has entry and decoded-section budget") + check(stats.msa_misses == 3 and stats.msa_hits >= 17, "hot switches reuse parsed MSA metadata") + check(stats.msa_entries <= 128, "MSA cache has an entry limit") + Metin2AnimPlayer.clear_clip_cache() + check(Metin2AnimPlayer.get_clip_cache_stats().msa_entries == 0, "MSA cache clears with clips") + a.anim.set_time(0.5) + check(a.model.get_visual_aabb().size.length() > 0, "clearing cache does not invalidate active clip") + a.free() + b.free() + await process_frame + print("animation_cache_test: failures=%d stats=%s" % [failures, stats]) + quit(1 if failures else 0) diff --git a/project/animation_cache_test.gd.uid b/project/animation_cache_test.gd.uid new file mode 100644 index 00000000..985312db --- /dev/null +++ b/project/animation_cache_test.gd.uid @@ -0,0 +1 @@ +uid://cmwgj04n10rly diff --git a/project/app_flow.gd b/project/app_flow.gd index ee2f8453..784cdc8e 100644 --- a/project/app_flow.gd +++ b/project/app_flow.gd @@ -84,6 +84,36 @@ func start(assets_root: String = "", injected_client: Node = null) -> void: func state() -> int: return _state +# Read-only adapter for the packaged playable test. It deliberately returns +# summaries rather than the private UI/game nodes so a report cannot retain a +# stale scene reference across reconnect or map reload. +func get_playable_snapshot() -> Dictionary: + var stage := "LOGIN" + match _state: + SELECT: stage = "SELECT" + GAME: stage = "GAME" + var selected: Dictionary = {} + for character in _chars: + if int(character.get("index", -1)) == _selected_char_slot: + selected = {"index": _selected_char_slot, "name_present": not String(character.get("name", "")).is_empty()} + break + var scene_ready := false + var map_key := "" + if _game != null and is_instance_valid(_game) and _game.has_method("get_playable_context"): + var context: Dictionary = _game.get_playable_context() + scene_ready = bool(context.get("scene_ready", false)) + map_key = String(context.get("map_path", "")) + return {"stage": stage, "selected_character": selected, + "character_count": _chars.size(), "scene_ready": scene_ready, + "map_key": map_key, "reconnecting": _reconnecting_game} + +# Internal test adapter: callers must treat returned Node references as +# borrowed and reacquire them after every scene/reconnect transition. +func get_playable_context() -> Dictionary: + if _game == null or not is_instance_valid(_game) or not _game.has_method("get_playable_context"): + return {} + return _game.get_playable_context() + # --- client 信号 ------------------------------------------------------------- func _wire_client() -> void: diff --git a/project/app_flow_lifecycle_test.gd b/project/app_flow_lifecycle_test.gd index 1cb6310c..4888e3fb 100644 --- a/project/app_flow_lifecycle_test.gd +++ b/project/app_flow_lifecycle_test.gd @@ -48,6 +48,9 @@ func _init() -> void: "AppLifecycle survives login screen construction") _ck(client.get_parent() == flow, "AppFlow keeps the same client beside lifecycle coordinator") + var login_snapshot: Dictionary = flow.get_playable_snapshot() + _ck(login_snapshot.get("stage", "") == "LOGIN" and not login_snapshot.get("scene_ready", true), + "playable snapshot is a read-only LOGIN summary") # A live game keeps its scene during a transient disconnect. ReconnectUI # owns the retry while the next character list silently re-enters the last @@ -67,6 +70,9 @@ func _init() -> void: _ck(flow.state() == AppFlow.GAME and not flow._reconnecting_game and not flow._reconnect.is_showing(), "successful reconnect restores GAME and hides overlay") + var game_snapshot: Dictionary = flow.get_playable_snapshot() + _ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true), + "playable snapshot reports GAME after reconnect") flow.queue_free() await process_frame diff --git a/project/character_winding_test.gd b/project/character_winding_test.gd index 19bedc85..65b4115c 100644 --- a/project/character_winding_test.gd +++ b/project/character_winding_test.gd @@ -56,6 +56,33 @@ func run() -> void: if indices(models[0]).size() != 4: failures += 1 printerr("FAIL: body and hair surfaces must both be exercised") + # Changes must update the visible mesh immediately, even while animation is paused. + for model in models: + model.set("hair_gr2", "") + if indices(model).size() != 3: + failures += 1 + printerr("FAIL: detached hair remains in rendered mesh") + model.set("hair_gr2", base.path_join("hair/hair_2_1.gr2")) + if indices(model).size() != 4: + failures += 1 + printerr("FAIL: replacement hair missing") + model.set("hair_skin", base.path_join("warrior_hair_01.dds")) + var mi: MeshInstance3D = model.find_children("*", "MeshInstance3D", true, false)[0] + var hair: ShaderMaterial = mi.get_active_material(3) + var expected: Image = preload("res://ui/ui_assets.gd").load_dds_image(base.path_join("warrior_hair_01.dds")) + var actual: Image = hair.get_shader_parameter("albedo_tex").get_image() + actual.convert(Image.FORMAT_RGBA8) + expected.convert(Image.FORMAT_RGBA8) + actual.clear_mipmaps() + expected.clear_mipmaps() + if actual.get_data() != expected.get_data() or not hair.shader.code.contains("cull_disabled"): + failures += 1 + printerr("FAIL: replacement hair texture or two-sided material not applied") + if OS.get_environment("MTGODOT_GPUSKIN") == "1": + var body: ShaderMaterial = mi.get_active_material(0) + if int(body.get_shader_parameter("mode")) != 0: + failures += 1 + printerr("FAIL: body incorrectly reclassified as hair") print("character_winding_test: failures=", failures) for node in animations + models: node.queue_free() diff --git a/project/character_winding_test.gd.uid b/project/character_winding_test.gd.uid new file mode 100644 index 00000000..4ace63ab --- /dev/null +++ b/project/character_winding_test.gd.uid @@ -0,0 +1 @@ +uid://dg8ihlbqw6jss diff --git a/project/client_main.gd b/project/client_main.gd index 3c0cac0e..0f649937 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -8,9 +8,14 @@ extends Node const AppFlow = preload("res://app_flow.gd") const LiveSmokeTest = preload("res://live_smoke_test.gd") const PackageRenderTest = preload("res://package_render_test.gd") +const PlayableLiveTest = preload("res://playable_live_test.gd") +const ForestMapRenderTest = preload("res://forest_map_render_test.gd") const NetTrace = preload("res://net_trace.gd") func _ready() -> void: + if OS.get_environment("MT_TEST_MODE") == "particle_exit": + add_child(preload("res://particle_exit_test.gd").new()) + return # MT_TEST_MODE=render:包内离线渲染自检,不起 AppFlow、不连服务器。 # 导出模板不接受 --script,这是导出包里唯一能跑离线渲染回归的入口。 if OS.get_environment("MT_TEST_MODE") == "render": @@ -19,6 +24,11 @@ func _ready() -> void: t.name = "PackageRenderTest" add_child(t) return + if OS.get_environment("MT_TEST_MODE") == "forest_render": + var forest := ForestMapRenderTest.new() + forest.name = "ForestMapRenderTest" + add_child(forest) + return # 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic # DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。 if OS.get_environment("MT_PROTOCOL").is_empty(): @@ -44,3 +54,8 @@ func _ready() -> void: smoke.name = "LiveSmokeTest" add_child(smoke) smoke.setup(af, af.client) + if OS.get_environment("MT_TEST_MODE") == "playable": + var playable := PlayableLiveTest.new() + playable.name = "PlayableLiveTest" + add_child(playable) + playable.setup(af, af.client) diff --git a/project/combat_fx_test.gd b/project/combat_fx_test.gd index a995c442..85618c06 100644 --- a/project/combat_fx_test.gd +++ b/project/combat_fx_test.gd @@ -63,6 +63,8 @@ class FakeAnim extends Node: class FakeView extends Node3D: var states := [] var anim := FakeAnim.new() + func _init() -> void: + add_child(anim) func set_anim_state(s): states.append(s) class FakeHud extends Node: @@ -300,6 +302,7 @@ func _run() -> void: # 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击 var fp := FakeProto.new() + get_root().add_child(fp) fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER} np.proto = fp np._main_vid = 1000 diff --git a/project/dds_lifecycle_test.gd b/project/dds_lifecycle_test.gd new file mode 100644 index 00000000..d2744e02 --- /dev/null +++ b/project/dds_lifecycle_test.gd @@ -0,0 +1,22 @@ +extends SceneTree +const UiAssets = preload("res://ui/ui_assets.gd") +func _init() -> void: + var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds") + var before := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)) + var images: Array[Image] = [] + var failed := false + for i in range(20): + var decoded := UiAssets.load_dds_image(path) + if decoded == null or decoded.is_empty(): + failed = true + else: + images.append(decoded) + var after := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)) + failed = failed or before != after or images.size() != 20 + for decoded in images: + failed = failed or decoded.get_data() != images[0].get_data() + if failed: + printerr("FAIL: DDS helper ownership/images before=%d after=%d images=%d" % [before, after, images.size()]) + else: + print("PASS: dds_lifecycle_test (20 decodes, node count unchanged, images survive helper release)") + quit(1 if failed else 0) diff --git a/project/dds_lifecycle_test.gd.uid b/project/dds_lifecycle_test.gd.uid new file mode 100644 index 00000000..1a29345f --- /dev/null +++ b/project/dds_lifecycle_test.gd.uid @@ -0,0 +1 @@ +uid://buf6xm7130vgl diff --git a/project/effect_color_operation_test.gd b/project/effect_color_operation_test.gd new file mode 100644 index 00000000..842530aa --- /dev/null +++ b/project/effect_color_operation_test.gd @@ -0,0 +1,116 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +const OPERATIONS := [2, 3, 4, 5, 6, 8] +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var fx := EffectPlayer.new() + var materials: Array = [] + var white := Image.create(2, 2, false, Image.FORMAT_RGBA8) + white.fill(Color.WHITE) + var texture := ImageTexture.create_from_image(white) + for destination in [6, 2]: + for operation in OPERATIONS: + fx.build({"particles": [{"particle": {"BillboardType": 1, "ColorOperationType": operation, + "SrcBlendType": 5, "DestBlendType": destination}}]}) + var material: Material = fx._emitters[-1].material_override + if operation == 4: + check(material is StandardMaterial3D, "ordinary modulation unchanged") + material.albedo_texture = texture + else: + check(material is ShaderMaterial, "operation %d has dedicated material" % operation) + if material is ShaderMaterial: + check(material.get_shader_parameter("color_operation") == operation, "texture-stage operation") + check(material.get_shader_parameter("color_gain") == (2.0 if operation == 5 else (4.0 if operation == 6 else 1.0)), "RGB gain") + check(material.get_shader_parameter("billboard_mode") == BaseMaterial3D.BILLBOARD_PARTICLES, "particle rotation preserved") + material.set_shader_parameter("albedo_texture", texture) + materials.append(material) + if DisplayServer.get_name() != "headless": + await pixels(materials) + else: + print("SKIP: color-operation pixel checks require rendering") + fx.free() + var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse") + var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) + var count := 0 + for particle in spec.get("particles", []): + if int(particle.particle.get("ColorOperationType", 4)) != 5: + continue + var prop: Dictionary = particle.particle.duplicate(true) + prop.erase("TextureFiles") # real properties, without texture-loader coupling + var real_fx := EffectPlayer.new() + real_fx.build({"particles": [{"particle": prop}]}) + var material: Material = real_fx._emitters[0].material_override + check(material is ShaderMaterial, "real MODULATE2X material") + if material is ShaderMaterial: + check(material.get_shader_parameter("color_gain") == 2.0, "real MODULATE2X gain") + check(material.get_shader_parameter("billboard_mode") == BaseMaterial3D.BILLBOARD_FIXED_Y, "real Y billboard preserved") + count += 1 + real_fx.free() + check(count > 0, "real MODULATE2X coverage") + print("real color-operation particles=%d" % count) + print("effect_color_operation_test: failures=%d" % failures) + quit(1 if failures else 0) +func pixels(materials: Array) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(96, 96) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var env := WorldEnvironment.new() + env.environment = Environment.new() + env.environment.background_mode = Environment.BG_COLOR + env.environment.background_color = Color.BLACK + viewport.add_child(env) + var camera := Camera3D.new() + viewport.add_child(camera) + camera.position = Vector3(0, 0, 3) + camera.look_at(Vector3.ZERO) + camera.current = true + var quad := MeshInstance3D.new() + var source := QuadMesh.new() + source.size = Vector2(2, 2) + var arrays := source.get_mesh_arrays() + var colors := PackedColorArray() + colors.resize(arrays[Mesh.ARRAY_VERTEX].size()) + colors.fill(Color(0.6, 0.25, 0.125, 0.5)) + arrays[Mesh.ARRAY_COLOR] = colors + quad.mesh = ArrayMesh.new() + quad.mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + viewport.add_child(quad) + for tint in [Color.WHITE, Color(0.5, 0.75, 0.25, 0.8)]: + var texture_image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + texture_image.fill(tint) + var decoded := texture_image.get_pixel(0, 0).srgb_to_linear() + var texture := ImageTexture.create_from_image(texture_image) + for i in materials.size(): + if materials[i] is ShaderMaterial: + materials[i].set_shader_parameter("albedo_texture", texture) + else: + materials[i].albedo_texture = texture + quad.material_override = materials[i] + await process_frame + await process_frame + await RenderingServer.frame_post_draw + var pixel := viewport.get_texture().get_image().get_pixel(48, 48).srgb_to_linear() + var operation: int = OPERATIONS[i % OPERATIONS.size()] + var vertex := Vector3(0.6, 0.25, 0.125) + var texel := Vector3(decoded.r, decoded.g, decoded.b) + var expected := vertex * texel + match operation: + 2: expected = vertex + 3: expected = texel + 5: expected *= 2.0 + 6: expected *= 4.0 + 8: expected = vertex + texel - Vector3.ONE * 0.5 + expected = expected.clamp(Vector3.ZERO, Vector3.ONE) * (0.5 * decoded.a) + var actual := Vector3(pixel.r, pixel.g, pixel.b) + check(actual.distance_to(expected) < 0.025, "op=%d tint=%s RGB/alpha got=%s expected=%s" % [operation, tint, actual, expected]) + print("color-operation pixel cases=24") + viewport.queue_free() + await process_frame diff --git a/project/effect_color_operation_test.gd.uid b/project/effect_color_operation_test.gd.uid new file mode 100644 index 00000000..20841263 --- /dev/null +++ b/project/effect_color_operation_test.gd.uid @@ -0,0 +1 @@ +uid://dds0frgva7yxj diff --git a/project/effect_color_test.gd b/project/effect_color_test.gd new file mode 100644 index 00000000..badfaeec --- /dev/null +++ b/project/effect_color_test.gd @@ -0,0 +1,83 @@ +extends SceneTree + +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 + +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) + +func effective(pm: ParticleProcessMaterial, age: float) -> Color: + return pm.color * pm.color_ramp.gradient.sample(age) if pm.color_ramp else pm.color + +func _init() -> void: + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": { + "TimeEventColorRed": [[0.0, 0.2], [0.5, 0.8], [1.0, 0.4]], + "TimeEventColorGreen": [[0.25, 0.4], [0.75, 0.8]], + "TimeEventColorBlue": [[0.0, 0.5]], + "TimeEventAlpha": [[0.0, 1.0], [1.0, 0.0]]}}, + {"particle": {"TimeEventColorRed": [[0.0, 0.5]], "TimeEventAlpha": [[0.0, 1.0]]}}, + {"particle": {"TimeEventColorBlue": [[0.0, 0.2], [1.0, 0.8]]}}, + {"particle": {}}]}) + var pm: ParticleProcessMaterial = fx._emitters[0].process_material + check(pm.color == Color.WHITE, "neutral base prevents double tint") + check(effective(pm, 0.0).is_equal_approx(Color(0.2, 0.4, 0.5, 1.0)), "birth RGBA") + check(effective(pm, 0.25).is_equal_approx(Color(0.5, 0.4, 0.5, 0.75)), "independent green knot") + check(effective(pm, 0.5).is_equal_approx(Color(0.8, 0.6, 0.5, 0.5)), "middle RGBA") + check(effective(pm, 0.75).is_equal_approx(Color(0.6, 0.8, 0.5, 0.25)), "late RGBA") + check(effective(pm, 1.0).is_equal_approx(Color(0.4, 0.8, 0.5, 0.0)), "end RGBA") + check(effective(fx._emitters[1].process_material, 0.5).is_equal_approx(Color(0.5, 1.0, 1.0)), + "constant 0.5 tint remains 0.5, not 0.25") + check(effective(fx._emitters[2].process_material, 0.5).is_equal_approx(Color(1.0, 1.0, 0.5)), + "RGB animates without alpha track") + check(effective(fx._emitters[3].process_material, 0.5) == Color.WHITE, "missing tracks default white") + fx.free() + var malformed := EffectPlayer.new() + var cleaned: GradientTexture1D = malformed._color_ramp({"TimeEventColorRed": [ + [1.0, 0.8], [], [0.0, 0.1], [0.0, 0.2], [NAN, 0.5], [0.5, INF], ["bad", 0.4]]}) + check(cleaned.gradient.sample(0.5).is_equal_approx(Color(0.5, 1, 1)), + "unordered/duplicate keys normalized and invalid values ignored") + malformed.free() + real_resources() + print("effect_color_test: failures=%d" % failures) + quit(1 if failures else 0) + +# Independent scalar interpolation oracle for the sorted, valid asset tracks. +func reference(rows: Array, age: float) -> float: + if rows.is_empty(): + return 1.0 + if age <= float(rows[0][0]): + return float(rows[0][1]) + for i in range(1, rows.size()): + if age <= float(rows[i][0]): + var fraction := (age - float(rows[i - 1][0])) / (float(rows[i][0]) - float(rows[i - 1][0])) + return float(rows[i - 1][1]) * (1.0 - fraction) + float(rows[i][1]) * fraction + return float(rows[-1][1]) + +func real_resources() -> void: + var count := 0 + for name in ["warrior/effect/palbang_4_sword.mse", "assassin/effect/beam_blow.mse", + "sura/effect/pabeopsul_4_blow.mse", "shaman/effect/noejeon_making_1.mse"]: + var path := AssetRoot.path().path_join("PC/ymir work/pc/" + name) + var parsed: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) + check(not parsed.get("particles", []).is_empty(), "real resource loaded: " + name) + for particle in parsed.get("particles", []): + # Exercise real RGBA through material construction; texture loading and + # emission geometry are deliberately outside this color-only regression. + var prop: Dictionary = {} + for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]: + prop[key] = particle.particle.get(key, []) + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": prop}]}) + var pm: ParticleProcessMaterial = fx._emitters[0].process_material + for step in range(41): + var age := float(step) / 40.0 + var expected := Color(reference(prop.TimeEventColorRed, age), + reference(prop.TimeEventColorGreen, age), reference(prop.TimeEventColorBlue, age), + reference(prop.TimeEventAlpha, age)) + check(effective(pm, age).is_equal_approx(expected), "real RGBA %s particle %d t=%f" % [name, count, age]) + count += 1 + fx.free() + print("real color tracks: particles=%d samples=%d" % [count, count * 41]) diff --git a/project/effect_color_test.gd.uid b/project/effect_color_test.gd.uid new file mode 100644 index 00000000..7c2cd358 --- /dev/null +++ b/project/effect_color_test.gd.uid @@ -0,0 +1 @@ +uid://cl2sjnmvtvr3q diff --git a/project/effect_emission_test.gd b/project/effect_emission_test.gd new file mode 100644 index 00000000..7d4964f2 --- /dev/null +++ b/project/effect_emission_test.gd @@ -0,0 +1,28 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var fx := EffectPlayer.new() + for attached in [0, 1]: + fx.build({"particles": [{"emitter": {"CycleLength": 0.8, "CycleLoopEnable": 0, + "TimeEventLifeTime": [[0, 0.05]]}, "particle": {"AttachEnable": attached}}]}) + root.add_child(fx) + fx.play(true) + for i in range(2): + check(fx._emitters[i].local_coords == bool(i), "AttachEnable determines local/world simulation") + check(not fx._emitters[i].one_shot, "effect cleanup must not impose single GPU lifetime") + await create_timer(0.3).timeout + for emitter in fx._emitters: + check(emitter.emitting, "short-lived particles still emit inside source cycle") + await create_timer(0.6).timeout + for emitter in fx._emitters: + check(not emitter.emitting, "source cycle still stops emission") + fx.free() + print("effect_emission_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/effect_emission_test.gd.uid b/project/effect_emission_test.gd.uid new file mode 100644 index 00000000..5f95b014 --- /dev/null +++ b/project/effect_emission_test.gd.uid @@ -0,0 +1 @@ +uid://b6olq0ioo7c0f diff --git a/project/effect_faces_test.gd b/project/effect_faces_test.gd new file mode 100644 index 00000000..2188f2b6 --- /dev/null +++ b/project/effect_faces_test.gd @@ -0,0 +1,64 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + for kind in [4, 5]: + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": {"BillboardType": kind, "ColorOperationType": 4}}]}) + var emitter: GPUParticles3D = fx._emitters[0] + var mesh := emitter.draw_pass_1 + var arrays := mesh.surface_get_arrays(0) + var angles := [-30.0, 30.0] if kind == 4 else [0.0, -60.0, 60.0] + check(arrays[Mesh.ARRAY_VERTEX].size() == angles.size() * 6, "one quad per reference face") + for i in angles.size(): + for v in 6: + check(is_equal_approx(arrays[Mesh.ARRAY_TEX_UV2][i * 6 + v].x, deg_to_rad(angles[i])), "reference face angle") + check(emitter.material_override is ShaderMaterial, "custom face shader") + check(emitter.material_override.get_shader_parameter("billboard_mode") == 5, "multiface mode") + if DisplayServer.get_name() != "headless": + await pixels(mesh, emitter.material_override, kind) + fx.free() + print("effect_faces_test: failures=%d" % failures) + quit(1 if failures else 0) +func pixels(mesh: Mesh, material: ShaderMaterial, kind: int) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(128, 128) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var world := WorldEnvironment.new() + world.environment = Environment.new() + world.environment.background_mode = Environment.BG_COLOR + world.environment.background_color = Color.BLACK + viewport.add_child(world) + var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image)) + var instance := MeshInstance3D.new() + instance.mesh = mesh + instance.material_override = material + viewport.add_child(instance) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 1.28 + viewport.add_child(camera) + for angle in [0.0, 0.7]: + camera.position = Vector3(sin(angle) * 3, 0, cos(angle) * 3) + camera.look_at(Vector3.ZERO) + await process_frame + await RenderingServer.frame_post_draw + var pixels := viewport.get_texture().get_image() + var width := 0 + for x in 128: + if pixels.get_pixel(x, 64).r > 0.5: width += 1 + # Default width .64m: 2FACE projects cos(30deg), 3FACE includes a full front face. + var expected := 64.0 * (cos(PI / 6) if kind == 4 else 1.0) + check(absf(width - expected) <= 3.0, "Metal projection kind=%d camera=%f width=%d expected=%f" % [kind, angle, width, expected]) + viewport.free() + await process_frame diff --git a/project/effect_faces_test.gd.uid b/project/effect_faces_test.gd.uid new file mode 100644 index 00000000..5cb335a8 --- /dev/null +++ b/project/effect_faces_test.gd.uid @@ -0,0 +1 @@ +uid://1mm2fl01mhtn diff --git a/project/effect_lie_test.gd b/project/effect_lie_test.gd new file mode 100644 index 00000000..cef67693 --- /dev/null +++ b/project/effect_lie_test.gd @@ -0,0 +1,57 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": {"BillboardType": 3, "ColorOperationType": 4}}]}) + var material: Material = fx._emitters[0].material_override + check(material is ShaderMaterial, "LIE no longer uses camera-facing StandardMaterial") + if material is ShaderMaterial: + check(material.get_shader_parameter("billboard_mode") == 4, "horizontal custom billboard selected") + if DisplayServer.get_name() != "headless": + await pixels(material) + fx.free() + print("effect_lie_test: failures=%d" % failures) + quit(1 if failures else 0) +func pixels(material: ShaderMaterial) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(96, 96) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var environment := WorldEnvironment.new() + environment.environment = Environment.new() + environment.environment.background_mode = Environment.BG_COLOR + environment.environment.background_color = Color.BLACK + viewport.add_child(environment) + var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image)) + var mesh := MeshInstance3D.new() + mesh.mesh = QuadMesh.new() + mesh.mesh.size = Vector2(1.5, 1.5) + mesh.material_override = material + viewport.add_child(mesh) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 3 + viewport.add_child(camera) + for top in [true, false]: + camera.position = Vector3(0, 3, 0) if top else Vector3(0, 0, 3) + camera.look_at(Vector3.ZERO, Vector3.FORWARD if top else Vector3.UP) + await process_frame + await RenderingServer.frame_post_draw + var pixels := viewport.get_texture().get_image() + var lit := 0 + for y in 96: + for x in 96: + if pixels.get_pixel(x, y).r > 0.5: lit += 1 + check(lit > 1500 if top else lit < 100, "horizontal quad visibility top=%s pixels=%d" % [top, lit]) + viewport.free() + await process_frame diff --git a/project/effect_lie_test.gd.uid b/project/effect_lie_test.gd.uid new file mode 100644 index 00000000..c55b58ee --- /dev/null +++ b/project/effect_lie_test.gd.uid @@ -0,0 +1 @@ +uid://2do65c7c5cj3 diff --git a/project/effect_playback_test.gd b/project/effect_playback_test.gd new file mode 100644 index 00000000..e51f7a76 --- /dev/null +++ b/project/effect_playback_test.gd @@ -0,0 +1,88 @@ +extends SceneTree + +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 + +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) + +func _init() -> void: + call_deferred("run") + +func fixture(): + var fx := EffectPlayer.new() + fx.build({"particles": [{"start_time": 0.2, + "emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1}}], + "lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]}) + # A synthetic two-frame mesh isolates playback from MDE asset decoding. + var host := Node3D.new() + var mesh := MeshInstance3D.new() + mesh.material_override = StandardMaterial3D.new() + host.add_child(mesh) + fx.add_child(host) + fx._mesh_nodes.append(host) + fx._mesh_states.append({"node": host, "clock": 0.0, "start_time": 0.0, + "frame_delay": 0.5, "duration": 1.0, "children": [{"node": mesh, + "frames": [{"visibility": 1.0}, {"visibility": 0.5}], + "meshes": [ArrayMesh.new(), ArrayMesh.new()], "frame": -1}]}) + root.add_child(fx) + fx.set_process(false) # Advance deterministically; SceneTree timers still run. + return fx + +func run() -> void: + var fx = fixture() + var emitter: GPUParticles3D = fx._emitters[0] + check(not emitter.emitting, "build does not emit before play") + fx.play(true) + check(not emitter.emitting, "play respects delay immediately") + fx._process(0.25) + check(emitter.emitting, "particle starts on playback clock") + fx._process(0.35) + check(fx._mesh_states[0].children[0].frame == 1, "mesh reached second frame") + fx.stop() + fx._process(0.01) + check(not fx._lights[0].visible, "stopped light stays hidden") + check(not fx._mesh_nodes[0].visible, "stopped mesh stays hidden") + check(is_equal_approx(fx._light_states[0].clock, 0.6), "stop freezes CPU clock") + await create_timer(0.3).timeout + check(not emitter.emitting, "old delayed start cannot revive stopped emitter") + fx.play(false) + check(not emitter.one_shot, "normal replay restores source loop mode") + check(is_zero_approx(fx._mesh_states[0].clock), "mesh replay resets clock") + check(is_zero_approx(fx._light_states[0].clock), "light replay resets clock") + check(fx._mesh_states[0].children[0].frame == 0, "mesh replay restores first frame") + check(fx._lights[0].visible, "light replay restores visibility") + fx._process(0.1) + check(not emitter.emitting, "replay uses new start delay") + fx._process(0.1) + check(emitter.emitting, "replay starts at delay boundary") + # Old forced-one-shot cleanup must not delete a later persistent playback. + await create_timer(2.5).timeout + check(is_instance_valid(fx), "old auto-free cannot delete replay") + if is_instance_valid(fx): + fx.free() + var once = fixture() + once.play(true) + once._process(once._longest_life() + 0.6) + check(once.is_queued_for_deletion(), "current one-shot still cleans up") + if not once.is_queued_for_deletion(): + once.free() + await process_frame + # Exercise the actual SceneTree process path, not just manual clock updates. + var live = fixture() + live.set_process(true) + live.play() + await create_timer(0.35).timeout + check(live._emitters[0].emitting, "SceneTree starts delayed emission") + live.stop() + var stopped_clock: float = live._particle_states[0].clock + await create_timer(0.1).timeout + check(not live._emitters[0].emitting and not live._lights[0].visible, + "SceneTree does not reactivate stopped effect") + check(is_equal_approx(live._particle_states[0].clock, stopped_clock), + "SceneTree leaves stopped clock unchanged") + live.free() + print("effect_playback_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/effect_playback_test.gd.uid b/project/effect_playback_test.gd.uid new file mode 100644 index 00000000..41dfdd34 --- /dev/null +++ b/project/effect_playback_test.gd.uid @@ -0,0 +1 @@ +uid://48lu45ir8yo5 diff --git a/project/effect_random_rotation_test.gd b/project/effect_random_rotation_test.gd new file mode 100644 index 00000000..e7e8f37e --- /dev/null +++ b/project/effect_random_rotation_test.gd @@ -0,0 +1,77 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: call_deferred("run") +func run() -> void: + var fx := EffectPlayer.new() + fx.build({"particles": [{"emitter": {"TimeEventLifeTime": [[0, 1.0]]}, "particle": { + "BillboardType": 1, "RotationType": 4, "RotationSpeed": 90, "ColorOperationType": 4}}]}) + var emitter: GPUParticles3D = fx._emitters[0] + var pm: ParticleProcessMaterial = emitter.process_material + var material: ShaderMaterial = emitter.material_override + check(pm.angular_velocity_min == 0 and pm.angular_velocity_max == 0, "no second angular integration") + check(pm.anim_offset_min == 0 and pm.anim_offset_max == 1 and pm.anim_speed_max == 0, "stable per-birth sign seed") + check(material.get_shader_parameter("random_rotation") == true, "random rotation enabled") + check(is_equal_approx(material.get_shader_parameter("rotation_speed"), PI / 2), "full magnitude in radians") + if DisplayServer.get_name() != "headless": await pixels(material) + fx.free() + print("effect_random_rotation_test: failures=%d" % failures) + quit(1 if failures else 0) +func pixels(material: ShaderMaterial) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(128, 128) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var environment := WorldEnvironment.new() + environment.environment = Environment.new() + environment.environment.background_mode = Environment.BG_COLOR + environment.environment.background_color = Color.BLACK + viewport.add_child(environment) + var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + material.set_shader_parameter("albedo_texture", ImageTexture.create_from_image(image)) + material.set_shader_parameter("rotation_lifetime", 1.0) + var node := MultiMeshInstance3D.new() + node.multimesh = MultiMesh.new() + node.multimesh.transform_format = MultiMesh.TRANSFORM_3D + node.multimesh.use_custom_data = true + node.multimesh.mesh = QuadMesh.new() + node.multimesh.mesh.size = Vector2(1.2, 0.16) + node.multimesh.instance_count = 1 + node.multimesh.set_instance_transform(0, Transform3D.IDENTITY) + node.material_override = material + viewport.add_child(node) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 2.0 + camera.position.z = 3 + viewport.add_child(camera) + var results: Array[Image] = [] + for seed in [0.25, 0.75]: + node.multimesh.set_instance_custom_data(0, Color(0, 0.5, seed, 1)) + await process_frame + await RenderingServer.frame_post_draw + results.append(viewport.get_texture().get_image()) + var positive := 0 + var negative := 0 + var mismatch := 0 + var lit := 0 + for y in 128: + for x in 128: + var a := results[0].get_pixel(x, y).r > 0.5 + var b := results[1].get_pixel(x, y).r > 0.5 + if a: + lit += 1 + positive += (2 * x - 127) * (2 * y - 127) + if b: negative += (2 * x - 127) * (2 * y - 127) + if a != (results[1].get_pixel(127 - x, y).r > 0.5): mismatch += 1 + check(lit > 300, "rendered visible particles") + check(positive * float(negative) < 0, "opposite rotation directions") + check(mismatch < 60, "equal magnitude produces mirrored GPU images") + viewport.free() + await process_frame diff --git a/project/effect_random_rotation_test.gd.uid b/project/effect_random_rotation_test.gd.uid new file mode 100644 index 00000000..b66a0e3d --- /dev/null +++ b/project/effect_random_rotation_test.gd.uid @@ -0,0 +1 @@ +uid://7if0yf3v2fuw diff --git a/project/effect_render_regression_test.gd b/project/effect_render_regression_test.gd new file mode 100644 index 00000000..ceff5329 --- /dev/null +++ b/project/effect_render_regression_test.gd @@ -0,0 +1,63 @@ +extends SceneTree + +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 + +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) + +func _init() -> void: + var fx := EffectPlayer.new() + # D3D8: SRCALPHA=5, INVSRCALPHA=6; both mesh and particle use this helper. + check(fx._blend(5, 6) == BaseMaterial3D.BLEND_MODE_MIX, "source-alpha transparency") + check(fx._blend(5, 2) == BaseMaterial3D.BLEND_MODE_ADD, "additive glow preserved") + fx.build({"particles": [{"start_time": 0.5, + "emitter": {"CycleLength": 1.0, "CycleLoopEnable": 1, "LoopCount": 2}, + "particle": {"SrcBlendType": 5, "DestBlendType": 6}}]}) + var state: Dictionary = fx._particle_states[0] + var emitter: GPUParticles3D = state.node + check(emitter.material_override.blend_mode == BaseMaterial3D.BLEND_MODE_MIX, + "built particle uses alpha material") + fx._advance_particle(state, 0.25) + check(is_equal_approx(state.clock, 0.25) and not emitter.emitting, + "stationary emitter advances clock and respects start delay") + # Simulate the play timer starting emission; isolate cycle gating from timers. + emitter.emitting = true + fx._advance_particle(state, 2.0) + check(emitter.emitting and not state.emission_stopped, "finite loop before end") + fx._advance_particle(state, 0.25) + check(not emitter.emitting and state.emission_stopped, "stationary finite loop ends") + state.clock = 0.0 + state.start_time = 0.0 + state.cycle_loop = false + state.emission_stopped = false + emitter.emitting = true + fx._advance_particle(state, 1.0) + check(not emitter.emitting and state.emission_stopped, "stationary non-loop ends") + state.clock = 0.0 + state.cycle_loop = true + state.loop_count = 0 + state.emission_stopped = false + emitter.emitting = true + fx._advance_particle(state, 10.0) + check(emitter.emitting and not state.emission_stopped, "unlimited loop preserved") + check(emitter.position == Vector3.ZERO, "missing position stays at origin") + fx.free() + var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse") + var parsed: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) + check(not parsed.get("particles", []).is_empty(), "real shaman effect loaded") + var real_fx := EffectPlayer.new() + real_fx.build(parsed, AssetRoot.path()) + var alpha_count := 0 + for i in parsed.get("particles", []).size(): + var prop: Dictionary = parsed.particles[i].particle + if int(prop.get("SrcBlendType", 0)) == 5 and int(prop.get("DestBlendType", 0)) == 6: + alpha_count += 1 + check(real_fx._emitters[i].material_override.blend_mode == BaseMaterial3D.BLEND_MODE_MIX, + "real shaman alpha particle %d" % i) + check(alpha_count > 0, "real fixture exercises alpha blending") + real_fx.free() + print("effect_render_regression_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/effect_render_regression_test.gd.uid b/project/effect_render_regression_test.gd.uid new file mode 100644 index 00000000..36e6cd3a --- /dev/null +++ b/project/effect_render_regression_test.gd.uid @@ -0,0 +1 @@ +uid://sxn37poee1rf diff --git a/project/effect_rotation_test.gd b/project/effect_rotation_test.gd new file mode 100644 index 00000000..a242ac95 --- /dev/null +++ b/project/effect_rotation_test.gd @@ -0,0 +1,103 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + var fx := EffectPlayer.new() + for kind in [0, 2, 3]: + fx.build({"particles": [{"particle": {"RotationType": kind, "RotationSpeed": 180, + "RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 60, "BillboardType": 1}}]}) + var g: GPUParticles3D = fx._emitters[-1] + var pm: ParticleProcessMaterial = g.process_material + var speed := 0.0 if kind == 0 else (180.0 if kind == 2 else -180.0) + check(pm.angular_velocity_min == speed and pm.angular_velocity_max == speed, + "constant rotation type %d uses degrees without random slowing" % kind) + check(pm.angle_min == 30.0 and pm.angle_max == 60.0, "initial angle range type %d" % kind) + check(g.material_override.billboard_mode == BaseMaterial3D.BILLBOARD_PARTICLES, + "camera billboard consumes particle rotation type %d" % kind) + check(fx._billboard(0) == BaseMaterial3D.BILLBOARD_DISABLED, "non-billboard unchanged") + check(fx._billboard(1) == BaseMaterial3D.BILLBOARD_ENABLED, "mesh camera billboard unchanged") + check(fx._billboard(2) == BaseMaterial3D.BILLBOARD_FIXED_Y, "source Y enum is two") + check(fx._billboard(4) != BaseMaterial3D.BILLBOARD_FIXED_Y, "two-face is not misclassified as Y") + fx.free() + var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse") + var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) + check(not spec.get("particles", []).is_empty(), "real shaman rotation resource loaded") + var count := 0 + for particle in spec.get("particles", []): + var prop: Dictionary = particle.particle.duplicate(true) + var kind := int(prop.get("RotationType", 0)) + if kind != 2 and kind != 3: + continue + # Isolate rotation/material configuration without loading original textures. + prop.erase("TextureFiles") + var real_fx := EffectPlayer.new() + real_fx.build({"particles": [{"particle": prop}]}) + var pm: ParticleProcessMaterial = real_fx._emitters[0].process_material + var expected := float(prop.get("RotationSpeed", 0)) * (1.0 if kind == 2 else -1.0) + check(pm.angular_velocity_min == expected and pm.angular_velocity_max == expected, "real constant speed") + check(pm.angle_min == float(prop.get("RotationRandomStartingBegin", 0)) and + pm.angle_max == float(prop.get("RotationRandomStartingEnd", 0)), "real initial angle range") + count += 1 + real_fx.free() + check(count > 0, "real constant-rotation coverage") + print("real rotation tracks: particles=%d" % count) + time_event_rotation() + real_time_event_rotation() + print("effect_rotation_test: failures=%d" % failures) + quit(1 if failures else 0) + +func time_event_rotation() -> void: + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": {"RotationType": 1, "RotationSpeed": 999, + "TimeEventRotation": [[0.0, 180.0], [0.5, -180.0], [1.0, 0.0]], + "RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 30}}, + {"particle": {"RotationType": 1, "RotationSpeed": 999}}]}) + var pm: ParticleProcessMaterial = fx._emitters[0].process_material + check(pm.angular_velocity_curve is CurveTexture, "TIME_EVENT has a speed curve") + if pm.angular_velocity_curve is CurveTexture: + var curve: Curve = pm.angular_velocity_curve.curve + # Godot 4.7 curve branch integrates sampled value * base rate directly + # into CUSTOM.x radians; unlike the constant branch it does not convert. + check(is_equal_approx(curve.sample(0.0) * pm.angular_velocity_min, PI), "birth rate is pi radians/sec") + check(is_zero_approx(curve.sample(0.25)), "speed passes through zero") + check(is_equal_approx(curve.sample(0.75) * pm.angular_velocity_min, -PI / 2.0), "negative rate preserved") + check(is_zero_approx(curve.sample(1.0)), "end stops without erasing earlier motion") + var angle := deg_to_rad(pm.angle_min) + for i in range(2000): + angle += curve.sample((float(i) + 0.5) / 2000.0) * pm.angular_velocity_min * 2.0 / 2000.0 + check(absf(angle - deg_to_rad(-60.0)) < 0.0001, "two-second lifetime integrates speed plus initial angle") + var empty: ParticleProcessMaterial = fx._emitters[1].process_material + check(empty.angular_velocity_curve is CurveTexture, "empty TIME_EVENT remains an explicit zero curve") + if empty.angular_velocity_curve is CurveTexture: + check(is_zero_approx(empty.angular_velocity_curve.curve.sample(0.5)), "empty track ignores RotationSpeed fallback") + fx.free() + +func real_time_event_rotation() -> void: + var count := 0 + var samples := 0 + for name in ["jeungsok_blow", "8chojeonbu", "jeungryeok_hand"]: + var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/" + name + ".mse") + var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) + check(not spec.get("particles", []).is_empty(), "real TIME_EVENT file " + name) + for particle in spec.get("particles", []): + if int(particle.particle.get("RotationType", 0)) != 1: + continue + var rows: Array = particle.particle.get("TimeEventRotation", []) + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": {"RotationType": 1, "TimeEventRotation": rows}}]}) + var pm: ParticleProcessMaterial = fx._emitters[0].process_material + check(pm.angular_velocity_curve is CurveTexture, "real TIME_EVENT material") + if pm.angular_velocity_curve is CurveTexture: + for row in rows: + if float(row[0]) >= 0.0 and float(row[0]) <= 1.0: + var actual: float = pm.angular_velocity_curve.curve.sample(float(row[0])) * pm.angular_velocity_min + check(is_equal_approx(actual, deg_to_rad(float(row[1]))), "real TIME_EVENT radians " + name) + samples += 1 + count += 1 + fx.free() + check(count > 0 and samples > 0, "real time-dependent speed coverage") + print("real time-event tracks: particles=%d keys=%d" % [count, samples]) diff --git a/project/effect_rotation_test.gd.uid b/project/effect_rotation_test.gd.uid new file mode 100644 index 00000000..4a88d679 --- /dev/null +++ b/project/effect_rotation_test.gd.uid @@ -0,0 +1 @@ +uid://cb3kfnretl1a5 diff --git a/project/effect_scale_test.gd b/project/effect_scale_test.gd new file mode 100644 index 00000000..a72066f9 --- /dev/null +++ b/project/effect_scale_test.gd @@ -0,0 +1,81 @@ +extends SceneTree + +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 + +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) + +func _init() -> void: + var fx := EffectPlayer.new() + fx.build({"particles": [{"emitter": {"TimeEventSizeX": [[0, 10]], "TimeEventSizeY": [[0, 30]]}, + "particle": {"TimeEventScaleX": [[0, 0], [1, 4]], "TimeEventScaleY": [[0, 3], [1, 1]]}}, + {"emitter": {"TimeEventSizeX": [[0, 0]], "TimeEventSizeY": [[0, 0]]}, "particle": {}}]}) + var g: GPUParticles3D = fx._emitters[0] + var pm: ParticleProcessMaterial = g.process_material + check(g.draw_pass_1.size.is_equal_approx(Vector2(0.2, 0.6)), "independent half-sizes become full metre dimensions") + check(pm.scale_min == 1.0 and pm.scale_max == 1.0, "no invented random size factor") + check(pm.scale_curve is CurveXYZTexture, "independent XYZ scale texture") + if pm.scale_curve is CurveXYZTexture: + var tex: CurveXYZTexture = pm.scale_curve + check(is_equal_approx(tex.curve_x.sample(0.25), 1.0), "X interpolates linearly") + check(is_equal_approx(tex.curve_y.sample(0.25), 2.5), "Y has independent interpolation") + check(is_equal_approx(tex.curve_x.sample(1.0), 4.0), "scale above two is retained") + check(is_equal_approx(tex.curve_z.sample(0.5), 1.0), "depth scale remains neutral") + var empty: GPUParticles3D = fx._emitters[1] + check(empty.draw_pass_1.size == Vector2.ZERO, "zero source size is not made visible") + var defaults: ParticleProcessMaterial = empty.process_material + if defaults.scale_curve is CurveXYZTexture: + check(defaults.scale_curve.curve_x.sample(1.0) == 1.0 and defaults.scale_curve.curve_y.sample(1.0) == 1.0, + "absent scale tracks stay constant instead of shrinking") + else: + check(false, "default independent curves") + fx.free() + real_resources() + print("effect_scale_test: failures=%d" % failures) + quit(1 if failures else 0) + +func real_resources() -> void: + var count := 0 + var samples := 0 + for name in ["warrior/effect/palbang_4_sword.mse", "assassin/effect/beam_blow.mse", + "sura/effect/pabeopsul_4_blow.mse", "shaman/effect/noejeon_making_1.mse"]: + var path := AssetRoot.path().path_join("PC/ymir work/pc/" + name) + var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) + check(not spec.get("particles", []).is_empty(), "real size resource loaded: " + name) + for particle in spec.get("particles", []): + var fx := EffectPlayer.new() + # Only source size/scale tracks: not a full texture or emission test. + fx.build({"particles": [{"emitter": { + "TimeEventSizeX": particle.emitter.get("TimeEventSizeX", []), + "TimeEventSizeY": particle.emitter.get("TimeEventSizeY", [])}, "particle": { + "TimeEventScaleX": particle.particle.get("TimeEventScaleX", []), + "TimeEventScaleY": particle.particle.get("TimeEventScaleY", [])}}]}) + var texture: Texture2D = fx._emitters[0].process_material.scale_curve + check(texture is CurveXYZTexture, "real XY texture " + name) + if texture is CurveXYZTexture: + for axis in range(2): + var key := "TimeEventScaleX" if axis == 0 else "TimeEventScaleY" + var curve: Curve = texture.curve_x if axis == 0 else texture.curve_y + var rows: Array = particle.particle.get(key, []) + for i in rows.size(): + var time := float(rows[i][0]) + var duplicate := i > 0 and float(rows[i - 1][0]) == time + if time >= 0.0 and time <= 1.0 and not duplicate: + check(is_equal_approx(curve.sample(time), float(rows[i][1])), "real scale key %s %s t=%f got=%f expected=%f" % [name, key, time, curve.sample(time), float(rows[i][1])]) + samples += 1 + if i > 0 and not duplicate: + var mid := (float(rows[i - 1][0]) + time) * 0.5 + var value := (float(rows[i - 1][1]) + float(rows[i][1])) * 0.5 + if mid >= 0.0 and mid <= 1.0: + # Duplicate-key jumps occupy 1e-4 of normalized age; + # permit the resulting small interpolation deviation. + var after_jump := i > 1 and float(rows[i - 2][0]) == float(rows[i - 1][0]) + var matches := absf(curve.sample(mid) - value) < 0.001 if after_jump else is_equal_approx(curve.sample(mid), value) + check(matches, "real linear midpoint %s %s t=%f got=%f expected=%f" % [name, key, mid, curve.sample(mid), value]) + samples += 1 + count += 1 + fx.free() + print("real scale tracks: particles=%d samples=%d" % [count, samples]) diff --git a/project/effect_scale_test.gd.uid b/project/effect_scale_test.gd.uid new file mode 100644 index 00000000..1adb9524 --- /dev/null +++ b/project/effect_scale_test.gd.uid @@ -0,0 +1 @@ +uid://b5703nwexgqtl diff --git a/project/effect_space_test.gd b/project/effect_space_test.gd new file mode 100644 index 00000000..c4c61dca --- /dev/null +++ b/project/effect_space_test.gd @@ -0,0 +1,22 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + var effect := EffectPlayer.new() + var rows := [[0, "MOVING_TYPE_DIRECT", 100, 200, 300], [1, "MOVING_TYPE_DIRECT", 200, 400, 600]] + check(effect._position_at(rows, 0).is_equal_approx(Vector3(1, 3, -2)), "source Z goes up and Y goes south") + check(effect._position_at(rows, 0.5).is_equal_approx(Vector3(1.5, 4.5, -3)), "movement interpolation uses converted vectors") + effect.build({"particles": [{"position": rows, "emitter": {"EmittingDirection": [0, 0, 1], "EmitterShape": 1}}], + "lights": [{"position": rows}]}) + var emitter: GPUParticles3D = effect._emitters[0] + check(emitter.position.is_equal_approx(Vector3(1, 3, -2)), "particle spawn shares movement coordinates") + check(effect._lights[0].position.is_equal_approx(emitter.position), "light and particle coordinates agree") + check(emitter.process_material.direction.is_equal_approx(Vector3.UP), "source upwards emission is upwards") + check(emitter.process_material.emission_box_extents.y < 0.01, "ellipse lies on ground plane") + effect.free() + print("effect_space_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/effect_space_test.gd.uid b/project/effect_space_test.gd.uid new file mode 100644 index 00000000..37701323 --- /dev/null +++ b/project/effect_space_test.gd.uid @@ -0,0 +1 @@ +uid://cfm5yxb2yufon diff --git a/project/effect_surface_test.gd b/project/effect_surface_test.gd new file mode 100644 index 00000000..7d76d494 --- /dev/null +++ b/project/effect_surface_test.gd @@ -0,0 +1,77 @@ +extends SceneTree +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + var texture := ImageTexture.create_from_image(image) + var fx := EffectPlayer.new() + fx.build({"particles": [{"particle": {"BillboardType": 0, "SrcBlendType": 5, "DestBlendType": 6}}]}) + var particle_mat: StandardMaterial3D = fx._emitters[0].material_override + # A plain white texture and a static quad isolate material face visibility. + particle_mat.albedo_texture = texture + var materials := [particle_mat, fx._mesh_material({"BlendingSrcType": 5, "BlendingDestType": 6}, [texture])] + for i in materials.size(): + check(materials[i].cull_mode == BaseMaterial3D.CULL_DISABLED, "effect material %d is two-sided" % i) + check(materials[i].depth_draw_mode == BaseMaterial3D.DEPTH_DRAW_DISABLED, "effect material %d does not write depth" % i) + check(not materials[i].no_depth_test, "effect material %d still tests world depth" % i) + if DisplayServer.get_name() != "headless": + materials.append(preload("res://fx/particle_color_material.gd").build(particle_mat, 5)) + materials.append(preload("res://fx/particle_color_material.gd").build(particle_mat, 6)) + await visual(materials) + else: + print("SKIP: pixel checks require a rendering backend") + fx.free() + print("effect_surface_test: failures=%d" % failures) + quit(1 if failures else 0) +func visual(materials: Array) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(96, 96) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var environment := WorldEnvironment.new() + environment.environment = Environment.new() + environment.environment.background_mode = Environment.BG_COLOR + environment.environment.background_color = Color.BLACK + viewport.add_child(environment) + var quad := MeshInstance3D.new() + quad.mesh = QuadMesh.new() + quad.mesh.size = Vector2(2, 2) + viewport.add_child(quad) + var camera := Camera3D.new() + viewport.add_child(camera) + camera.current = true + var blocker := MeshInstance3D.new() + blocker.mesh = BoxMesh.new() + blocker.mesh.size = Vector3(2, 2, 0.1) + var black := StandardMaterial3D.new() + black.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + black.albedo_color = Color.BLACK + blocker.material_override = black + viewport.add_child(blocker) + for i in materials.size(): + quad.material_override = materials[i] + for side in [1.0, -1.0]: + camera.position = Vector3(0, 0, side * 3.0) + camera.look_at(Vector3.ZERO) + blocker.visible = false + var color: Color = await center(viewport) + check(color.r > 0.8, "material %d side %d visible pixel=%s" % [i, int(side), color]) + blocker.position = Vector3(0, 0, side) + blocker.visible = true + color = await center(viewport) + check(color.r < 0.05, "material %d side %d occluded by world" % [i, int(side)]) + viewport.queue_free() + await process_frame +func center(viewport: SubViewport) -> Color: + await process_frame + await process_frame + await RenderingServer.frame_post_draw + return viewport.get_texture().get_image().get_pixel(48, 48) diff --git a/project/effect_surface_test.gd.uid b/project/effect_surface_test.gd.uid new file mode 100644 index 00000000..abfa7f25 --- /dev/null +++ b/project/effect_surface_test.gd.uid @@ -0,0 +1 @@ +uid://cqj76kscctrvh diff --git a/project/effect_texture_animation_test.gd b/project/effect_texture_animation_test.gd new file mode 100644 index 00000000..f6e7962e --- /dev/null +++ b/project/effect_texture_animation_test.gd @@ -0,0 +1,83 @@ +extends SceneTree +const Materials = preload("res://fx/particle_color_material.gd") +const EffectPlayer = preload("res://fx/effect_player.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var fx := EffectPlayer.new() + var textures: Array[Texture2D] = [] + for color in [Color.RED, Color.GREEN, Color.BLUE]: + var img := Image.create(4, 4, false, Image.FORMAT_RGBA8) + img.fill(color) + textures.append(ImageTexture.create_from_image(img)) + var frames := fx._texture_array(textures) + check(frames != null and frames.get_layers() == 3, "independent texture-array slices") + var base := StandardMaterial3D.new() + base.blend_mode = BaseMaterial3D.BLEND_MODE_MIX + base.albedo_texture = textures[0] + var mat := Materials.build(base, 4, frames, {"TexAniType": 1, "TexAniDelay": 0.1}, 1.0) + check(mat is ShaderMaterial, "ordinary modulation supports per-particle frames") + if DisplayServer.get_name() != "headless": + await pixels(mat) + else: + print("SKIP: texture animation pixels require a renderer") + fx.free() + print("effect_texture_animation_test: failures=%d" % failures) + quit(1 if failures else 0) +func pixels(material: ShaderMaterial) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(96, 96) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var environment := WorldEnvironment.new() + environment.environment = Environment.new() + environment.environment.background_mode = Environment.BG_COLOR + environment.environment.background_color = Color.BLACK + viewport.add_child(environment) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 3.0 + camera.position.z = 3.0 + viewport.add_child(camera) + var instance := MultiMeshInstance3D.new() + var mesh := QuadMesh.new() + mesh.size = Vector2(0.8, 0.8) + var multi := MultiMesh.new() + multi.transform_format = MultiMesh.TRANSFORM_3D + multi.use_custom_data = true + multi.use_colors = true + multi.mesh = mesh + multi.instance_count = 2 + for i in 2: + multi.set_instance_transform(i, Transform3D(Basis.IDENTITY, Vector3(-0.6 + i * 1.2, 0, 0))) + multi.set_instance_color(i, Color.WHITE) + instance.multimesh = multi + instance.material_override = material + viewport.add_child(instance) + # Same material, different ages: old shared AnimatedTexture fails this. + for type in [0, 1, 2, 3, 4]: + material.set_shader_parameter("animation_type", type) + for age in [0.15, 0.25, 0.35]: + multi.set_instance_custom_data(0, Color(0, 0, 0.2, 0)) + multi.set_instance_custom_data(1, Color(0, age, 0.2, 0)) + await process_frame + await RenderingServer.frame_post_draw + var img := viewport.get_texture().get_image() + var newborn := img.get_pixel(29, 48) + check(newborn.r > 0.9 and newborn.g < 0.05, "newborn always starts at frame zero type=%d" % type) + var aged := img.get_pixel(67, 48) + if type in [0, 1, 2, 4]: + var step := int(floor(age / 0.1)) + var index := 0 if type == 0 else posmod(-step if type == 2 else step, 3) + var channels := [aged.r, aged.g, aged.b] + check(channels[index] > 0.9, "age selects expected frame type=%d age=%s" % [type, age]) + else: + check(maxf(aged.r, maxf(aged.g, aged.b)) > 0.9, "random frame selects one valid texture") + viewport.free() + await process_frame diff --git a/project/effect_texture_animation_test.gd.uid b/project/effect_texture_animation_test.gd.uid new file mode 100644 index 00000000..63876ff2 --- /dev/null +++ b/project/effect_texture_animation_test.gd.uid @@ -0,0 +1 @@ +uid://cedkk28y0ohf7 diff --git a/project/fly_target_bounds_test.gd b/project/fly_target_bounds_test.gd new file mode 100644 index 00000000..26ae376f --- /dev/null +++ b/project/fly_target_bounds_test.gd @@ -0,0 +1,42 @@ +extends SceneTree +const PlayerView = preload("res://ui/player_view.gd") +const Anchor = preload("res://fx/target_effect_anchor.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: call_deferred("run") +func run() -> void: + var old_mode := OS.get_environment("MTGODOT_GPUSKIN") + var centers := {} + for mode in ["0", "1"]: + OS.set_environment("MTGODOT_GPUSKIN", mode) + for race in 8: + var view := PlayerView.new() + check(view.build(AssetRoot.path(), race), "race builds") + root.add_child(view) + view.anim.set_process(false) + view.anim.set_time(0) + var result: Dictionary = view.model.get_fly_target_bounds() + check(result.valid, "source bone OBBs parsed race=%d mode=%s" % [race, mode]) + var bounds: AABB = result.bounds + check(bounds.size.is_finite() and bounds.size.length() > 50, "nonempty cm bounds") + var center: Vector3 = Anchor.fly_target_position(view) + check(center.distance_to(view.model.to_global(bounds.get_center())) < 0.00001, "source centre transforms once") + if mode == "0": centers[race] = center + else: check(center.distance_to(centers[race]) < 0.0001, "CPU/GPU target centres match") + var extra := MeshInstance3D.new() + extra.mesh = BoxMesh.new() + extra.position = Vector3(1000, 1000, 1000) + view.add_child(extra) + view.set_anim_state("attack") + view.anim.set_time(0.3) + check(Anchor.fly_target_position(view).distance_to(center) < 0.00001, "cached body sphere excludes attachments and later poses") + view.position = Vector3(5, 2, -3) + check(Anchor.fly_target_position(view).distance_to(center + view.position) < 0.00001, "cached local sphere follows actor translation") + view.free() + OS.set_environment("MTGODOT_GPUSKIN", old_mode) + await process_frame + print("fly_target_bounds_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/fly_target_bounds_test.gd.uid b/project/fly_target_bounds_test.gd.uid new file mode 100644 index 00000000..b21efe3e --- /dev/null +++ b/project/fly_target_bounds_test.gd.uid @@ -0,0 +1 @@ +uid://cayswx15kvish diff --git a/project/forest_map_render_test.gd b/project/forest_map_render_test.gd new file mode 100644 index 00000000..4779e4ab --- /dev/null +++ b/project/forest_map_render_test.gd @@ -0,0 +1,96 @@ +extends Node + +## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点。 +## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。 + +const GameScene = preload("res://game_scene.gd") +const Fixtures = preload("res://gamescene_test.gd") +const MobView = preload("res://ui/mob_view.gd") +const Config = preload("res://testing/playable_config.gd") +const PlayableReport = preload("res://testing/playable_report.gd") + +var failures := 0 + +func _ready() -> void: + call_deferred("run") + +func _fail(message: String) -> void: + failures += 1 + printerr("FOREST MAP FAIL: " + message) + +func run() -> void: + var map_key := OS.get_environment("MT_FOREST_MAP") + var config_path := OS.get_environment("MT_PLAYABLE_CONFIG") + var loaded_config := Config.load_file(config_path) if not config_path.is_empty() else {"ok": false, "config": {}} + if map_key.is_empty() and loaded_config.ok: + map_key = String(loaded_config.config.get("map_key", "")) + if map_key.is_empty(): + map_key = "outdoortrent/metin2_map_trent" + var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301 + var output := OS.get_environment("MT_TEST_OUTPUT") + if output.is_empty(): + output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system()) + DirAccess.make_dir_recursive_absolute(output) + var playable_report := PlayableReport.new() + playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {}) + get_tree().root.size = Vector2i(1280, 720) + print("FOREST MAP: loading %s" % map_key) + var client := Fixtures.FakeClient.new() + # Server-space values chosen inside the corresponding local map rectangle. + # GameScene/MapCoord performs the actual base-position conversion. + var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0 + var server_x := base_x + 12800.0 + var server_y := 12800.0 + client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true) + var scene := GameScene.new() + get_tree().root.add_child(scene) + await scene.setup(client, AssetRoot.path(), map_key) + print("FOREST MAP: GameScene setup complete") + if not scene._map_loaded(): + _fail("map did not load: %s" % map_key) + if not scene._model_built or scene.player == null: + _fail("local player model is not ready") + var entities := scene.get_node_or_null("Entities") + if entities == null: + _fail("GameScene entity mount is missing") + var positions := [Vector3(12.0, 0.0, 18.0), Vector3(15.0, 0.0, 21.0)] + var monster := MobView.new() + monster.name = "ForestTreeMonster_%d" % race + if not monster.build(AssetRoot.path(), null, race): + _fail("tree monster build failed race=%d" % race) + else: + entities.add_child(monster) + monster.position = positions[0] + monster.set_anim_state("wait") + client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0)) + await get_tree().process_frame + if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0: + _fail("tree monster has no visible geometry") + if scene.cam and scene.cam.has_method("snap_to_target"): + scene.cam.snap_to_target() + await get_tree().process_frame + await get_tree().process_frame + var image_name := "forest-map-%s.png" % map_key.get_file() + if DisplayServer.get_name() != "headless": + await RenderingServer.frame_post_draw + if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK: + _fail("map screenshot could not be saved") + var report := {"passed": failures == 0, "failures": failures, "map": map_key, + "tree_monster_race": race, "tree_monster_model": monster.model != null, + "map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "", + "assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless", + "scope": "production GameScene map load plus offline tree-monster placement; no server spawn"} + var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) + if file: + file.store_string(JSON.stringify(report, "\t")) + file.close() + var client_report_path := OS.get_environment("MT_TEST_REPORT") + if not client_report_path.is_empty(): + playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL", + "map=%s tree_monster_race=%d" % [map_key, race]) + playable_report.write(client_report_path) + print("FOREST MAP RENDER: ", JSON.stringify(report)) + scene.queue_free() + client.queue_free() + await get_tree().process_frame + get_tree().quit(1 if failures else 0) diff --git a/project/forest_mob_render_test.gd b/project/forest_mob_render_test.gd new file mode 100644 index 00000000..87604dd6 --- /dev/null +++ b/project/forest_mob_render_test.gd @@ -0,0 +1,124 @@ +extends SceneTree + +## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。 +## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。 + +const MobView = preload("res://ui/mob_view.gd") +const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] +const STATES := ["wait", "run", "attack", "damage", "dead"] +const SIZE := 640 + +var _output := "" +var _failures: Array[String] = [] +var _pose_bounds := {} +var _results: Array[Dictionary] = [] + +func _initialize() -> void: + call_deferred("run") + +func _fail(message: String) -> void: + _failures.append(message) + printerr("FOREST MOB FAIL: " + message) + +func run() -> void: + _output = OS.get_environment("MT_RENDER_OUTPUT") + if _output.is_empty(): + _output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system()) + DirAccess.make_dir_recursive_absolute(_output) + root.size = Vector2i(SIZE, SIZE) + _build_lighting() + var cam := Camera3D.new() + cam.fov = 38.0 + cam.near = 0.02 + root.add_child(cam) + cam.make_current() + for gpu in ["0", "1"]: + OS.set_environment("MTGODOT_GPUSKIN", gpu) + for race in RACES: + await _check_race(race, gpu, cam) + OS.set_environment("MTGODOT_GPUSKIN", "0") + var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES, + "states": STATES, "gpu_modes": ["0", "1"], "results": _results, + "assets": AssetRoot.path(), "viewport": str(root.size), + "headless": DisplayServer.get_name() == "headless", + "scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"} + var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE) + if file: + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "results": _results.size(), "output": _output})) + quit(0 if _failures.is_empty() else 1) + +func _check_race(race: int, gpu: String, cam: Camera3D) -> void: + var view := MobView.new() + view.name = "ForestMob_%d_%s" % [race, gpu] + root.add_child(view) + if not view.build(AssetRoot.path(), null, race): + _fail("race=%d GPU=%s build returned false" % [race, gpu]) + view.queue_free() + await process_frame + return + var row := {"race": race, "gpu_skin": gpu == "1", "states": {}} + for state in STATES: + var requested := view.get_motion_path(state) + if requested.is_empty() or not FileAccess.file_exists(requested): + _fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state]) + continue + view.set_anim_state(state) + if view.anim == null: + _fail("race=%d GPU=%s state=%s has no animation player" % [race, gpu, state]) + continue + view.anim.set_process(true) + await process_frame + var actual := String(view.anim.get("anim_path")) + if actual != requested: + _fail("race=%d GPU=%s state=%s resolved %s but played %s" % [race, gpu, state, requested, actual]) + var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 1.0 + var fraction := 0.5 + if state == "dead": + fraction = 0.95 + view.anim.call("set_time", maxf(0.0, duration * fraction)) + view.anim.set_process(false) + await process_frame + var bounds: AABB = view.model.call("get_visual_aabb") + if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0: + _fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state]) + _pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB()) + if gpu == "1": + var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB()) + var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005) + if cpu_bounds.size.length() <= 0.0 or cpu_bounds.position.distance_to(bounds.position) > tolerance or cpu_bounds.size.distance_to(bounds.size) > tolerance: + _fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance]) + var image_name := "" + if DisplayServer.get_name() != "headless": + var centre: Vector3 = view.model.global_transform * bounds.get_center() + var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0) + var distance: float = maxf(3.0, visual_size * 1.8) + cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance) + cam.look_at(centre, Vector3.UP) + await RenderingServer.frame_post_draw + image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state] + if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK: + _fail("race=%d state=%s screenshot save failed" % [race, state]) + row.states[state] = {"requested_motion": requested, "resolved_motion": actual, + "duration": duration, "sample_fraction": fraction, + "aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z], + "aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_name} + _results.append(row) + view.queue_free() + await process_frame + +func _build_lighting() -> void: + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-42, 155, 0) + sun.light_energy = 1.1 + root.add_child(sun) + var env := WorldEnvironment.new() + var settings := Environment.new() + settings.background_mode = Environment.BG_COLOR + settings.background_color = Color(0.08, 0.1, 0.14) + settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + settings.ambient_light_color = Color(0.55, 0.58, 0.65) + settings.ambient_light_energy = 0.8 + env.environment = settings + root.add_child(env) diff --git a/project/fx/effect_player.gd b/project/fx/effect_player.gd index f877edee..26635883 100644 --- a/project/fx/effect_player.gd +++ b/project/fx/effect_player.gd @@ -13,6 +13,9 @@ extends Node3D const UiAssets = preload("res://ui/ui_assets.gd") const Mde = preload("res://fx/mde.gd") +const EffectSpace = preload("res://fx/effect_space.gd") +const ParticleColorMaterial = preload("res://fx/particle_color_material.gd") +const ParticleFaces = preload("res://fx/particle_faces.gd") var spec := {} var assets_root := "" @@ -24,6 +27,8 @@ var _mesh_states: Array[Dictionary] = [] var _lights: Array[OmniLight3D] = [] var _light_states: Array[Dictionary] = [] var _bsphere_r := 0.0 +var _playing := false +var _cleanup_remaining := -1.0 static var _glow_tex: Texture2D @@ -48,41 +53,54 @@ func build(mse_spec: Dictionary, assets := "") -> void: _lights.append(light) func _process(delta: float) -> void: + if not _playing: + return for state in _particle_states: _advance_particle(state, delta) for state in _mesh_states: _advance_mesh(state, delta) for state in _light_states: _advance_light(state, delta) + if _cleanup_remaining >= 0.0: + _cleanup_remaining -= maxf(delta, 0.0) + if _cleanup_remaining <= 0.0: + queue_free() func play(force_one_shot := false) -> void: - # EffectPlayer can be reused by the registry. Reset the CPU-side clocks as - # well as the GPU emitters so cycle gates do not inherit the previous play. + # One playback clock owns starts and cleanup. No detached SceneTree timers + # may start or delete an effect after stop/replay, or while it is paused. + _playing = true + _cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0 for state in _particle_states: state["clock"] = 0.0 state["emission_stopped"] = false - for i in _emitters.size(): - var e := _emitters[i] - var st := float(_emitters[i].get_meta("start_time", 0.0)) - if force_one_shot: - e.one_shot = true - var tree: SceneTree = get_tree() if is_inside_tree() else null - if st <= 0.0 or tree == null: - e.restart() - e.emitting = true - else: - tree.create_timer(st).timeout.connect(func(): - if is_instance_valid(e): - e.restart() - e.emitting = true) - var tr: SceneTree = get_tree() if is_inside_tree() else null - if (force_one_shot or one_shot) and tr: - var total := _longest_life() + 0.5 - tr.create_timer(maxf(total, 1.5)).timeout.connect(queue_free) + state["emission_started"] = false + var e: GPUParticles3D = state["node"] + # Effect one_shot controls node cleanup, not a GPU emission cycle. + # Individual lifetime may be much shorter than the MSE emission window. + e.one_shot = false + e.restart() + e.emitting = false + e.position = _position_at(state.get("rows", []), 0.0) + _advance_particle(state, 0.0) + for state in _mesh_states: + state["clock"] = 0.0 + for child in state.get("children", []): + child["frame"] = -1 + child["texture_frame"] = -1 + _advance_mesh(state, 0.0) + for state in _light_states: + state["clock"] = 0.0 + _advance_light(state, 0.0) func stop() -> void: + # Existing particles may finish their lifetime; mesh/light emission stops now. + _playing = false + _cleanup_remaining = -1.0 for e in _emitters: e.emitting = false + for mesh in _mesh_nodes: + mesh.visible = false for light in _lights: light.visible = false @@ -109,6 +127,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: var emit: Dictionary = p.get("emitter", {}) var prop: Dictionary = p.get("particle", {}) var g := GPUParticles3D.new() + g.emitting = false g.set_meta("start_time", float(p.get("start_time", 0.0))) g.set_meta("emitter_advanced_type", int(_n(emit.get("EmitterAdvancedType", 0)))) g.set_meta("emitter_emit_from_edge", int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0) @@ -116,8 +135,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: # EffectLib stores effect coordinates in centimetres; the Godot scene is # metres. The position curve is evaluated again as the effect advances. - g.position = Vector3(float(positions[0][2]), float(positions[0][3]), - float(positions[0][4])) * 0.01 + g.position = _position_at(positions, 0.0) g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16)))) g.explosiveness = 0.0 @@ -128,7 +146,8 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: var cycle := _n(emit.get("CycleLength", 0.0)) if emit.has("CycleLength") else 0.05 var loop := int(_n(emit.get("CycleLoopEnable", 0))) var loop_count := int(_n(emit.get("LoopCount", 0))) - g.one_shot = (loop == 0) + g.one_shot = false # emission ends at the MSE clock gate, not one particle lifetime + g.local_coords = int(_n(prop.get("AttachEnable", 0))) != 0 g.set_meta("cycle_length", maxf(cycle, 0.0)) g.set_meta("cycle_loop", loop != 0) g.set_meta("loop_count", maxi(loop_count, 0)) @@ -140,11 +159,11 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: var shape := int(_n(emit.get("EmitterShape", 0))) var emit_from_edge := int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0 var radius := maxf(0.0, _n(emit.get("EmittingRadius", 10.0))) * 0.01 - var emitting_size := _vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])) * 0.01 + var emitting_size := EffectSpace.position(_vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0]))) match shape: 1: # CEmitterProperty::EMITTER_SHAPE_ELLIPSE (flat box approximation) pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX - pm.emission_box_extents = Vector3(maxf(radius, 0.01), maxf(radius, 0.01), 0.005) + pm.emission_box_extents = Vector3(maxf(radius, 0.01), 0.005, maxf(radius, 0.01)) 2: # CEmitterProperty::EMITTER_SHAPE_SQUARE pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX pm.emission_box_extents = Vector3(maxf(absf(emitting_size.x) * 0.5, 0.005), @@ -160,6 +179,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0), _last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0), _last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0)) + direction = EffectSpace.direction(direction) pm.direction = direction.normalized() if direction.length_squared() > 0.000001 else Vector3(0, 1, 0) pm.spread = 25.0 if direction.length_squared() <= 0.000001 else 0.0 # The reference computes OUTER/INNER velocity from each particle's radial @@ -175,44 +195,84 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: # 重力 var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0) pm.gravity = Vector3(0, -grav * 0.01, 0) - # 大小 - var sx := _last_val(emit.get("TimeEventSizeX", []), 32.0) * 0.01 - pm.scale_min = maxf(0.02, sx * 0.7) - pm.scale_max = maxf(0.03, sx) - pm.scale_curve = _curve_tex(prop.get("TimeEventScaleX", [[0, 1], [1, 0]])) - # 颜色渐变(RGB 曲线各取末值 + Alpha 曲线) - pm.color = _rgb(prop) - var ramp := _alpha_ramp(prop.get("TimeEventAlpha", []), pm.color) - if ramp: - pm.color_ramp = ramp + # EffectLib stores independent half-width/height. Lifetime scale is XY, + # not a uniform random multiplier. Birth-time size tracks remain approximated + # by their last value until per-particle emitter-clock sampling is supported. + var half_width := _last_val(emit.get("TimeEventSizeX", []), 32.0) + var half_height := _last_val(emit.get("TimeEventSizeY", []), 32.0) + pm.scale_min = 1.0 + pm.scale_max = 1.0 + var scale_texture := CurveXYZTexture.new() + scale_texture.curve_x = _scale_curve(prop.get("TimeEventScaleX", [])) + scale_texture.curve_y = _scale_curve(prop.get("TimeEventScaleY", [])) + scale_texture.curve_z = _scale_curve([]) + pm.scale_curve = scale_texture + # Godot multiplies color by color_ramp. Store RGBA only in the ramp so + # channels are not squared, and sample all four tracks over particle age. + pm.color = Color.WHITE + pm.color_ramp = _color_ramp(prop) # 旋转:ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、 - # 4=RANDOM_DIRECTION。Godot 没有完全相同的每粒子时间表,先保留方向和 - # 时间表末值,避免把 NONE 误当成恒速旋转。 + # 4=RANDOM_DIRECTION。TIME_EVENT 按粒子寿命采样转速再积分。 var rotation_type := int(_n(prop.get("RotationType", 0))) + var angle_begin := _n(prop.get("RotationRandomStartingBegin", 0.0)) + var angle_end := _n(prop.get("RotationRandomStartingEnd", 0.0)) + pm.angle_min = minf(angle_begin, angle_end) + pm.angle_max = maxf(angle_begin, angle_end) var rspeed := _n(prop.get("RotationSpeed", 0.0)) if rotation_type == 1: - rspeed = _last_val(prop.get("TimeEventRotation", []), rspeed) + var rotation_texture := CurveTexture.new() + rotation_texture.curve = _scale_curve(prop.get("TimeEventRotation", []), 0.0) + pm.angular_velocity_curve = rotation_texture + # Godot 4.7's curve branch accumulates directly in radians (CUSTOM.x), + # whereas the constant branch converts degrees internally. The curve + # retains source degrees/sec; this multiplier supplies the conversion. + pm.angular_velocity_min = deg_to_rad(1.0) + pm.angular_velocity_max = deg_to_rad(1.0) if rotation_type == 3: rspeed = -rspeed - if rotation_type != 0 and rspeed != 0.0: - pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3 - pm.angular_velocity_max = deg_to_rad(rspeed) + if rotation_type in [2, 3] and rspeed != 0.0: + # Both EffectLib and Godot's constant angular velocity use degrees/sec. + pm.angular_velocity_min = rspeed + pm.angular_velocity_max = rspeed + if rotation_type == 4: + # Choose a stable per-birth sign in the drawing shader, retaining full + # magnitude in both directions and the initial angle in CUSTOM.x. + pm.anim_speed_min = 0.0 + pm.anim_speed_max = 0.0 + pm.anim_offset_min = 0.0 + pm.anim_offset_max = 1.0 g.process_material = pm # 绘制网格 + 材质(billboard + 混合) - var qm := QuadMesh.new() - qm.size = Vector2(1, 1) - g.draw_pass_1 = qm + g.draw_pass_1 = ParticleFaces.build(Vector2(maxf(half_width, 0.0), maxf(half_height, 0.0)) * 0.02, + int(_n(prop.get("BillboardType", 1)))) var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # EffectInstance::OnRender disables culling and depth writes for FX, but + # keeps depth testing so terrain/buildings can still occlude the effect. + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1)))) + # Only particle billboards consume INSTANCE_CUSTOM.x (rotation). Keep the + # shared mesh-material mapping separate: meshes have no particle custom data. + if int(_n(prop.get("BillboardType", 1))) == 1: + mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES mat.billboard_keep_scale = true mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2)))) - var tex := _particle_texture(prop) - mat.albedo_texture = tex if tex else _glow() + var textures := _particle_textures(prop) + mat.albedo_texture = textures[0] if not textures.is_empty() else _glow() + var frames: Texture2DArray + if textures.size() > 1: + frames = _texture_array(textures) + # CUSTOM.z is a stable per-birth seed; CUSTOM.y is this particle's age. + # Do not use a shared AnimatedTexture wall clock for all particles. + pm.anim_speed_min = 0.0 + pm.anim_speed_max = 0.0 + pm.anim_offset_min = 0.0 + pm.anim_offset_max = 1.0 mat.vertex_color_use_as_albedo = true - g.material_override = mat + g.material_override = ParticleColorMaterial.build(mat, int(_n(prop.get("ColorOperationType", 4))), frames, prop, g.lifetime) _particle_states.append({ "node": g, "rows": positions, @@ -222,6 +282,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: "cycle_loop": loop != 0, "loop_count": maxi(loop_count, 0), "emission_stopped": false, + "emission_started": false, }) return g @@ -230,8 +291,7 @@ func _advance_particle(state: Dictionary, delta: float) -> void: if g == null or not is_instance_valid(g): return var rows: Array = state.get("rows", []) - if rows.is_empty(): - return + # Position tracks are optional; stationary emitters still obey lifecycle gates. var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) state["clock"] = clock var start := float(state.get("start_time", 0.0)) @@ -248,7 +308,11 @@ func _advance_particle(state: Dictionary, delta: float) -> void: g.emitting = false state["emission_stopped"] = true return - if not bool(state.get("emission_stopped", false)): + if _playing and not bool(state.get("emission_started", false)) and not bool(state.get("emission_stopped", false)): + g.restart() + g.emitting = true + state["emission_started"] = true + if not rows.is_empty() and not bool(state.get("emission_stopped", false)): g.position = _position_at(rows, local) func _build_mesh(m: Dictionary) -> Node3D: @@ -262,7 +326,7 @@ func _build_mesh(m: Dictionary) -> Node3D: n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename() var positions: Array = m.get("position", []) if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: - n.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01 + n.position = _position_at(positions, 0.0) var geometries: Array = mesh_data.get("geometries", []) var elements: Array = m.get("elements", []) @@ -328,7 +392,7 @@ func _build_light(l: Dictionary) -> OmniLight3D: light.name = "simple_light" var positions: Array = l.get("position", []) if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: - light.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01 + light.position = _position_at(positions, 0.0) var diffuse: Array = l.get("diffuse", [0.0, 0.0, 0.0, 1.0]) if diffuse.size() >= 4: light.light_color = Color(float(diffuse[0]), float(diffuse[1]), float(diffuse[2]), float(diffuse[3])) @@ -477,6 +541,8 @@ func _array_mesh(frame: Dictionary) -> ArrayMesh: func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.billboard_mode = _billboard(int(_n(element.get("BillboardType", 0)))) mat.blend_mode = _blend(int(_n(element.get("BlendingSrcType", 5))), int(_n(element.get("BlendingDestType", 2)))) @@ -578,7 +644,7 @@ func _position_at(rows, time: float) -> Vector3: var p0 := _position_vector(previous) var p1 := _position_vector(current) if String(previous[1]) == "MOVING_TYPE_BEZIER_CURVE" and previous.size() >= 8: - var control := Vector3(float(previous[5]), float(previous[6]), float(previous[7])) + var control := EffectSpace.direction(Vector3(float(previous[5]), float(previous[6]), float(previous[7]))) var middle := p0 + control var inv := 1.0 - t return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01 @@ -588,7 +654,7 @@ func _position_at(rows, time: float) -> Vector3: func _position_vector(row) -> Vector3: if row is Array and row.size() >= 5: - return Vector3(float(row[2]), float(row[3]), float(row[4])) + return EffectSpace.direction(Vector3(float(row[2]), float(row[3]), float(row[4]))) return Vector3.ZERO func _event_value(rows, time: float, fallback: float) -> float: @@ -624,10 +690,10 @@ func _vec3(v) -> Vector3: return Vector3(float(v[0]), float(v[1]), float(v[2])) return Vector3.ZERO -func _particle_texture(prop: Dictionary) -> Texture2D: +func _particle_textures(prop: Dictionary) -> Array[Texture2D]: var rows: Variant = prop.get("TextureFiles", []) if not rows is Array or rows.is_empty(): - return null + return [] # ParticleSystemData resolves relative texture names against the .mse # directory. UiAssets keeps the same behavior and uses the native DDS path. var mse_dir := String(spec.get("dir", "")) @@ -641,23 +707,29 @@ func _particle_texture(prop: Dictionary) -> Texture2D: tex = UiAssets.load_tex(assets_root, file) if tex: textures.append(tex) - if textures.is_empty(): - return null - var animation_type := int(_n(prop.get("TexAniType", 0))) - if textures.size() == 1 or animation_type == 0: - return textures[0] - # AnimatedTexture is shared by the draw pass, unlike the original per-particle - # frame index. This preserves the real frame order and delay for the common - # multi-file case; random-start variants remain deterministic until the GPU - # particle custom-data path is added. - if animation_type == 2: # TEXTURE_ANIMATION_TYPE_CCW - textures.reverse() - var animated := AnimatedTexture.new() - animated.fps = 1.0 / maxf(_n(prop.get("TexAniDelay", 0.05)), 0.001) - animated.frames = mini(textures.size(), 256) - for i in animated.frames: - animated.set_frame_texture(i, textures[i]) - return animated + return textures + +func _texture_array(textures: Array[Texture2D]) -> Texture2DArray: + var images: Array[Image] = [] + var width := 1 + var height := 1 + for texture in textures: + width = maxi(width, texture.get_width()) + height = maxi(height, texture.get_height()) + for texture in textures: + var img := texture.get_image() + if img == null or img.is_empty(): + return null + if img.is_compressed(): + if img.decompress() != OK: + return null + img.convert(Image.FORMAT_RGBA8) + img.clear_mipmaps() + if img.get_width() != width or img.get_height() != height: + img.resize(width, height, Image.INTERPOLATE_BILINEAR) + images.append(img) + var frames := Texture2DArray.new() + return frames if frames.create_from_images(images) == OK else null # List 表末行的值(列 idx,默认 col 1 = 时间后第一个数) func _last_val(rows, def: float, col := 1) -> float: @@ -667,68 +739,102 @@ func _last_val(rows, def: float, col := 1) -> float: return float(r[col]) return def -func _rgb(prop: Dictionary) -> Color: - return Color( - _last_val(prop.get("TimeEventColorRed", []), 1.0), - _last_val(prop.get("TimeEventColorGreen", []), 1.0), - _last_val(prop.get("TimeEventColorBlue", []), 1.0), - 1.0) - -# TimeEventScaleX rows [[t,v],...] -> CurveTexture -func _curve_tex(rows) -> CurveTexture: +# Independent lifetime channel, with linear tangents matching EffectLib. +func _scale_curve(rows, fallback := 1.0) -> Curve: + var values := {} + if rows is Array: + for row in rows: + if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int): + var time := float(row[0]) + var value := float(row[1]) + if is_finite(time) and is_finite(value): + if not values.has(time): + values[time] = [] + values[time].append(value) + var times := values.keys() + times.sort() + var track: Array = [] + for time in times: + for value in values[time]: + track.append([time, value]) + var points := {0.0: _event_value(track, 0.0, fallback), 1.0: _event_value(track, 1.0, fallback)} + for i in times.size(): + var time: float = times[i] + if time > 0.0 and time < 1.0: + points[time] = values[time][0] + if time >= 0.0 and time < 1.0 and values[time].size() > 1: + # EffectLib takes the first value exactly at a duplicate key, then + # departs from the last. A texture cannot encode a zero-width jump; + # preserve both sides using a narrow transition, not a long ramp. + var next_time: float = times[i + 1] if i + 1 < times.size() else 1.0 + # Keep wider than Curve's approximate-equality epsilon. + var after := time + minf(0.0001, (next_time - time) * 0.5) + points[after] = values[time][-1] var c := Curve.new() - c.min_value = 0.0 - c.max_value = 2.0 - if rows is Array and rows.size() > 0: - for r in rows: - if r is Array and r.size() >= 2: - c.add_point(Vector2(clampf(float(r[0]), 0, 1), float(r[1]))) - else: - c.add_point(Vector2(0, 1)) - c.add_point(Vector2(1, 0)) - var t := CurveTexture.new() - t.curve = c - return t + c.min_value = minf(0.0, points.values().min()) + c.max_value = maxf(1.0, points.values().max()) + times = points.keys() + times.sort() + for time in times: + c.add_point(Vector2(time, points[time]), 0.0, 0.0, Curve.TANGENT_LINEAR, Curve.TANGENT_LINEAR) + return c -func _alpha_ramp(rows, base: Color) -> GradientTexture1D: - if not (rows is Array) or rows.size() < 1: - return null +func _color_ramp(prop: Dictionary) -> GradientTexture1D: + # Match EffectLib ParticleSystemData: union the RGBA key times, then + # linearly evaluate each channel there. Preserve independent channel knots. + var times := {0.0: true, 1.0: true} + var tracks: Array = [] + for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]: + var points := {} + var rows = prop.get(key, []) + if rows is Array: + for row in rows: + if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int): + var time := float(row[0]) + var value := float(row[1]) + if is_finite(time) and is_finite(value): + points[time] = value + times[clampf(time, 0.0, 1.0)] = true + var ordered := points.keys() + ordered.sort() + var track: Array = [] + for time in ordered: + track.append([time, points[time]]) + tracks.append(track) + var ordered_times := times.keys() + ordered_times.sort() + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for time in ordered_times: + offsets.append(time) + colors.append(Color( + clampf(_event_value(tracks[0], time, 1.0), 0.0, 1.0), + clampf(_event_value(tracks[1], time, 1.0), 0.0, 1.0), + clampf(_event_value(tracks[2], time, 1.0), 0.0, 1.0), + clampf(_event_value(tracks[3], time, 1.0), 0.0, 1.0))) var grad := Gradient.new() - var pts := [] - for r in rows: - if r is Array and r.size() >= 2: - pts.append([clampf(float(r[0]), 0, 1), float(r[1])]) - if pts.is_empty(): - return null - pts.sort_custom(func(a, b): return a[0] < b[0]) - if pts[0][0] > 0.0: - pts.push_front([0.0, pts[0][1]]) - if pts[-1][0] < 1.0: - pts.append([1.0, pts[-1][1]]) - if pts.size() == 1: - pts.append([minf(1.0, pts[0][0] + 0.001), pts[0][1]]) - grad.offsets = PackedFloat32Array([float(pts[0][0]), float(pts[-1][0])]) - grad.colors = PackedColorArray([ - Color(base.r, base.g, base.b, clampf(float(pts[0][1]), 0, 1)), - Color(base.r, base.g, base.b, clampf(float(pts[-1][1]), 0, 1)), - ]) - for i in range(1, pts.size() - 1): - var pt = pts[i] - grad.add_point(float(pt[0]), Color(base.r, base.g, base.b, clampf(float(pt[1]), 0, 1))) + grad.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_LINEAR + grad.offsets = offsets + grad.colors = colors var gt := GradientTexture1D.new() gt.gradient = grad return gt func _billboard(bt: int) -> int: - # 0 = none/local, 1 = 面向相机, 4 = Y 轴 - if bt == 4: + # EffectLib Type.h: 0=NONE, 1=ALL, 2=Y. Particle 3=LIE, + # 4=2FACE and 5=3FACE still use a camera-facing approximation below. + if bt == 2: return BaseMaterial3D.BILLBOARD_FIXED_Y if bt == 0: return BaseMaterial3D.BILLBOARD_DISABLED return BaseMaterial3D.BILLBOARD_ENABLED func _blend(src: int, dst: int) -> int: - # D3D blend: 5=SRCALPHA 2=ONE 4=INVSRCALPHA 3=SRCCOLOR + # D3DBLEND: 5=SRCALPHA, 6=INVSRCALPHA, 2=ONE, 4=INVSRCCOLOR. + # Ordinary transparency must not fall through to additive glow. + if src == 5 and dst == 6: + return BaseMaterial3D.BLEND_MODE_MIX + # Remaining pairs retain the existing approximations, not exact D3D parity. if dst == 2: return BaseMaterial3D.BLEND_MODE_ADD if dst == 4: diff --git a/project/fx/effect_registry.gd b/project/fx/effect_registry.gd index beb9714b..28cc898b 100644 --- a/project/fx/effect_registry.gd +++ b/project/fx/effect_registry.gd @@ -97,3 +97,26 @@ func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot if fx: fx.global_position = global_pos return fx + +func spawn_motion(event: Dictionary, view: Node3D, world_parent: Node3D) -> Node3D: + var effect := spawn(String(event.get("effect", "")), world_parent, true) + if effect == null: + return null + var anchor := preload("res://fx/motion_effect_anchor.gd").new() + effect.add_child(anchor) + if not anchor.configure(view, event): + effect.free() + return null + return effect + +func spawn_target(event: Dictionary, target: Node3D, world_parent: Node3D) -> Node3D: + if not is_instance_valid(target) or bool(event.get("fishing_effect", false)): + return null # Fishing requires its separate water landing position. + var effect := spawn(String(event.get("effect", "")), world_parent, true) + if effect == null: return null + var anchor := preload("res://fx/target_effect_anchor.gd").new() + effect.add_child(anchor) + if not anchor.configure(target, event.get("pos", Vector3.ZERO), bool(event.get("following", false))): + effect.free() + return null + return effect diff --git a/project/fx/effect_space.gd b/project/fx/effect_space.gd new file mode 100644 index 00000000..14c4d1af --- /dev/null +++ b/project/fx/effect_space.gd @@ -0,0 +1,6 @@ +extends RefCounted +# All MSE/MDE vectors are Z-up. Convert orientation before the cm->m scale. +static func direction(source: Vector3) -> Vector3: + return Vector3(source.x, source.z, -source.y) +static func position(source: Vector3) -> Vector3: + return direction(source) * 0.01 diff --git a/project/fx/effect_space.gd.uid b/project/fx/effect_space.gd.uid new file mode 100644 index 00000000..aab82093 --- /dev/null +++ b/project/fx/effect_space.gd.uid @@ -0,0 +1 @@ +uid://c3htid787d4uy diff --git a/project/fx/mde.gd b/project/fx/mde.gd index 4a68b1b4..d34429c0 100644 --- a/project/fx/mde.gd +++ b/project/fx/mde.gd @@ -115,10 +115,10 @@ func parse_bytes(data: PackedByteArray) -> Dictionary: positions.resize(vertex_count) for i in vertex_count: var base := offset + i * 12 - positions[i] = Vector3( + positions[i] = preload("res://fx/effect_space.gd").position(Vector3( float(data.decode_float(base)), float(data.decode_float(base + 4)), - float(data.decode_float(base + 8))) * 0.01 + float(data.decode_float(base + 8)))) offset += vertex_count * 12 var indices := PackedInt32Array() diff --git a/project/fx/motion_effect_anchor.gd b/project/fx/motion_effect_anchor.gd new file mode 100644 index 00000000..eacc3e64 --- /dev/null +++ b/project/fx/motion_effect_anchor.gd @@ -0,0 +1,40 @@ +# 40250 ActorInstanceMotionEvent / ActorInstanceAttach: bone * offset * actor +# in row-vector order. The Godot equivalent is actor * offset * bone. +extends Node + +const SOURCE_TO_METRES := Transform3D(Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)), Vector3.ZERO) +var model: Node3D +var animator: Node +var event := {} + +func configure(view: Node3D, data: Dictionary) -> bool: + model = view.get("model") + animator = view.get("anim") + event = data.duplicate() + # Sample immediately, then after animation (default priority 0) every frame. + process_priority = 100 + if not update_anchor(): + return false + var attached := bool(event.get("attaching", false)) + set_process(not bool(event.get("independent", false)) and + (not attached or bool(event.get("following", false)))) + return true + +func update_anchor() -> bool: + if not is_instance_valid(model) or not model.is_inside_tree(): + return false + var bone := Transform3D.IDENTITY + if bool(event.get("attaching", false)) and not bool(event.get("independent", false)): + if not is_instance_valid(animator): + return false + var pose: Dictionary = animator.get_effect_bone_pose(String(event.get("bone", ""))) + if pose.is_empty(): + return false # No silent substitution of the actor origin for a missing bone. + bone = pose.transform + var offset := Transform3D(Basis.IDENTITY, event.get("pos", Vector3.ZERO)) + get_parent().global_transform = model.global_transform * offset * bone * SOURCE_TO_METRES.affine_inverse() + return true + +func _process(_delta: float) -> void: + if not update_anchor(): + get_parent().queue_free() diff --git a/project/fx/motion_effect_anchor.gd.uid b/project/fx/motion_effect_anchor.gd.uid new file mode 100644 index 00000000..c80c3c0e --- /dev/null +++ b/project/fx/motion_effect_anchor.gd.uid @@ -0,0 +1 @@ +uid://dvib4rwg2v55o diff --git a/project/fx/particle_color_material.gd b/project/fx/particle_color_material.gd new file mode 100644 index 00000000..f0494880 --- /dev/null +++ b/project/fx/particle_color_material.gd @@ -0,0 +1,139 @@ +# Particle-only legacy texture-stage operations. Static operation 4 keeps the +# StandardMaterial path; animated textures use per-particle array sampling. +# RGB is saturated before framebuffer blending; +# alpha remains texture alpha * particle alpha, never multiplied by RGB gain. +extends RefCounted + +static var _shaders := {} +const SOURCE := """ +shader_type spatial; +render_mode unshaded, cull_disabled, depth_draw_never, __BLEND__; +uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable; +uniform float color_gain = 2.0; +uniform int color_operation = 5; +uniform int billboard_mode = 0; +uniform bool random_rotation = false; +uniform float rotation_speed = 0.0; +uniform float rotation_lifetime = 1.0; +void vertex() { + float particle_angle = INSTANCE_CUSTOM.x; + if (random_rotation) { + float direction = INSTANCE_CUSTOM.z < 0.5 ? -1.0 : 1.0; + particle_angle += direction * rotation_speed * INSTANCE_CUSTOM.y * rotation_lifetime; + } + if (billboard_mode != 0) { + mat4 facing = mat4(normalize(INV_VIEW_MATRIX[0]), normalize(INV_VIEW_MATRIX[1]), + normalize(INV_VIEW_MATRIX[2]), MODEL_MATRIX[3]); + if (billboard_mode == 2) { + facing = mat4(vec4(normalize(cross(vec3(0.0, 1.0, 0.0), MAIN_CAM_INV_VIEW_MATRIX[2].xyz)), 0.0), + vec4(0.0, 1.0, 0.0, 0.0), + vec4(normalize(cross(MAIN_CAM_INV_VIEW_MATRIX[0].xyz, vec3(0.0, 1.0, 0.0))), 0.0), + MODEL_MATRIX[3]); + } else if (billboard_mode == 5) { + vec3 up = vec3(0.0, 1.0, 0.0); + vec3 right = cross(up, MAIN_CAM_INV_VIEW_MATRIX[2].xyz); + right = length(right) > 0.000001 ? normalize(right) : vec3(1.0, 0.0, 0.0); + // Source Y billboard's non-zero rotation branch (including its 90 degree offset). + float a = particle_angle; + if (a != 0.0) { + vec3 source_cross = -right; + up = up * -sin(a) - source_cross * cos(a); + right = -(source_cross * -sin(a) + vec3(0.0, 1.0, 0.0) * cos(a)); + } + // Source Z rotation becomes world Y rotation after coordinate conversion. + float c = cos(UV2.x), s = sin(UV2.x); + mat3 turn = mat3(vec3(c, 0.0, -s), vec3(0.0, 1.0, 0.0), vec3(s, 0.0, c)); + right = turn * right; + up = turn * up; + facing = mat4(vec4(right, 0.0), vec4(up, 0.0), vec4(cross(right, up), 0.0), MODEL_MATRIX[3]); + } else if (billboard_mode == 4) { + // EffectLib LIE: source Up=(cos(a),-sin(a),0), + // Cross=(sin(a),cos(a),0), converted from Z-up to Y-up. + float a = particle_angle; + facing = mat4(vec4(sin(a), 0.0, -cos(a), 0.0), + vec4(cos(a), 0.0, sin(a), 0.0), + vec4(0.0, 1.0, 0.0, 0.0), MODEL_MATRIX[3]); + } else if (billboard_mode == 3) { + float a = particle_angle; + facing *= mat4(vec4(cos(a), -sin(a), 0.0, 0.0), vec4(sin(a), cos(a), 0.0, 0.0), + vec4(0.0, 0.0, 1.0, 0.0), vec4(0.0, 0.0, 0.0, 1.0)); + } + mat4 scale_matrix = mat4(vec4(length(MODEL_MATRIX[0].xyz), 0.0, 0.0, 0.0), + vec4(0.0, length(MODEL_MATRIX[1].xyz), 0.0, 0.0), + vec4(0.0, 0.0, length(MODEL_MATRIX[2].xyz), 0.0), + vec4(0.0, 0.0, 0.0, 1.0)); + MODELVIEW_MATRIX = VIEW_MATRIX * facing * scale_matrix; + MODELVIEW_NORMAL_MATRIX = mat3(MODELVIEW_MATRIX); + } +} +void fragment() { + vec4 texel = texture(albedo_texture, UV); + vec3 result = texel.rgb * COLOR.rgb * color_gain; + if (color_operation == 2) { result = COLOR.rgb; } + else if (color_operation == 3) { result = texel.rgb; } + else if (color_operation == 8) { result = COLOR.rgb + texel.rgb - vec3(0.5); } + ALBEDO = clamp(result, vec3(0.0), vec3(1.0)); + ALPHA = texel.a * COLOR.a; +} +""" + +const ANIMATION_UNIFORMS := """ +uniform sampler2DArray frame_textures : source_color, filter_linear, repeat_enable; +uniform int frame_count = 1; +uniform int animation_type = 0; +uniform bool random_start = false; +uniform float frame_delay = 0.05; +uniform float particle_lifetime = 1.0; +varying flat float texture_frame; +float frame_random(float seed) { return fract(sin(seed * 127.1 + 311.7) * 43758.5453); } +""" +const ANIMATION_VERTEX := """ + float seed = INSTANCE_CUSTOM.z; + float steps = frame_delay > 0.000001 ? max(ceil(INSTANCE_CUSTOM.y * particle_lifetime / frame_delay) - 1.0, 0.0) : 0.0; + float count = float(frame_count); + float direction = animation_type == 2 ? -1.0 : 1.0; + if (animation_type == 4) { direction = seed < 0.5 ? 1.0 : -1.0; } + float first = animation_type == 4 && direction < 0.0 ? count - 1.0 : 0.0; + if (random_start) { first = floor(frame_random(seed + 1.0) * count); } + texture_frame = first; + if (animation_type != 0 && steps > 0.0) { + texture_frame = animation_type == 3 ? floor(frame_random(seed + steps * 3.17) * count) : mod(first + direction * steps, count); + } +""" + +static func build(base: StandardMaterial3D, operation: int, frames: Texture2DArray = null, + properties: Dictionary = {}, lifetime := 1.0) -> Material: + var lie := int(properties.get("BillboardType", -1)) == 3 + var multiface := int(properties.get("BillboardType", -1)) in [4, 5] + var random_rotation := int(properties.get("RotationType", 0)) == 4 + if operation not in [2, 3, 5, 6, 8] and frames == null and not lie and not multiface and not random_rotation: + return base + var blend := "blend_add" if base.blend_mode == BaseMaterial3D.BLEND_MODE_ADD else "blend_mix" + var key := blend + ("_animated" if frames else "") + if not _shaders.has(key): + var shader := Shader.new() + var source := SOURCE.replace("__BLEND__", blend) + if frames: + source = source.replace("void vertex() {", ANIMATION_UNIFORMS + "\nvoid vertex() {" + ANIMATION_VERTEX) + source = source.replace("texture(albedo_texture, UV)", "texture(frame_textures, vec3(UV, texture_frame))") + shader.code = source + _shaders[key] = shader + var material := ShaderMaterial.new() + material.shader = _shaders[key] + material.set_shader_parameter("albedo_texture", base.albedo_texture) + material.set_shader_parameter("color_operation", operation) + material.set_shader_parameter("color_gain", 2.0 if operation == 5 else (4.0 if operation == 6 else 1.0)) + material.set_shader_parameter("billboard_mode", 5 if multiface else (4 if lie else int(base.billboard_mode))) + if random_rotation and not lie and not multiface: + material.set_shader_parameter("billboard_mode", 5 if int(properties.get("BillboardType", 1)) == 2 else 3) + material.set_shader_parameter("random_rotation", random_rotation) + material.set_shader_parameter("rotation_speed", deg_to_rad(float(properties.get("RotationSpeed", 0)))) + material.set_shader_parameter("rotation_lifetime", lifetime) + if frames: + material.set_shader_parameter("frame_textures", frames) + material.set_shader_parameter("frame_count", frames.get_layers()) + material.set_shader_parameter("animation_type", int(properties.get("TexAniType", 0))) + material.set_shader_parameter("random_start", bool(properties.get("TexAniRandomStartFrameFlag", 0))) + material.set_shader_parameter("frame_delay", float(properties.get("TexAniDelay", 0.05))) + material.set_shader_parameter("particle_lifetime", lifetime) + return material diff --git a/project/fx/particle_color_material.gd.uid b/project/fx/particle_color_material.gd.uid new file mode 100644 index 00000000..737ef8ff --- /dev/null +++ b/project/fx/particle_color_material.gd.uid @@ -0,0 +1 @@ +uid://cu4fhnm5bep65 diff --git a/project/fx/particle_faces.gd b/project/fx/particle_faces.gd new file mode 100644 index 00000000..c68fcae2 --- /dev/null +++ b/project/fx/particle_faces.gd @@ -0,0 +1,31 @@ +# EffectLib TwoSideRenderer / ThreeSideRenderer angles, carried per face in UV2. +# One particle simulation drives every face (no independent emitter randomness). +extends RefCounted + +static func build(size: Vector2, billboard: int) -> Mesh: + if billboard not in [4, 5]: + var quad := QuadMesh.new() + quad.size = size + return quad + var angles := [-30.0, 30.0] if billboard == 4 else [0.0, -60.0, 60.0] + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var uv := PackedVector2Array() + var uv2 := PackedVector2Array() + var corners := [Vector2(0, 0), Vector2(0, 1), Vector2(1, 1), + Vector2(0, 0), Vector2(1, 1), Vector2(1, 0)] + for angle in angles: + for corner in corners: + vertices.append(Vector3((corner.x - 0.5) * size.x, (0.5 - corner.y) * size.y, 0)) + normals.append(Vector3(0, 0, 1)) + uv.append(corner) + uv2.append(Vector2(deg_to_rad(angle), 0)) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_TEX_UV] = uv + arrays[Mesh.ARRAY_TEX_UV2] = uv2 + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh diff --git a/project/fx/particle_faces.gd.uid b/project/fx/particle_faces.gd.uid new file mode 100644 index 00000000..57cbc78f --- /dev/null +++ b/project/fx/particle_faces.gd.uid @@ -0,0 +1 @@ +uid://d2act25qwmh5q diff --git a/project/fx/target_effect_anchor.gd b/project/fx/target_effect_anchor.gd new file mode 100644 index 00000000..5cfb0fe3 --- /dev/null +++ b/project/fx/target_effect_anchor.gd @@ -0,0 +1,43 @@ +# Target-following effects use actor-local offset. Snapshot effects use the +# fly-target's world position and a world-aligned offset (40250 type 10). +extends Node +const CONVERSION := Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)) +var target: WeakRef +var offset := Vector3.ZERO +func configure(node: Node3D, source_offset: Vector3, following: bool) -> bool: + if not is_instance_valid(node) or not node.is_inside_tree(): return false + target = weakref(node) + offset = CONVERSION * source_offset + process_priority = 100 + set_process(following) + if following: + _process(0.0) + else: + get_parent().global_position = fly_target_position(node) + offset + return true +static func fly_target_position(node: Node3D) -> Vector3: + # The reference model-index 0 sphere is built from bone OBBs, not the + # union of visible attachments or the rendered vertex AABB. + var models := node.find_children("*", "Metin2Model", true, false) + if node.has_method("get_fly_target_bounds"): models.push_front(node) + for model in models: + var result: Dictionary = model.get_fly_target_bounds() + if result.get("valid", false): + var body_bounds: AABB = result.bounds + return model.to_global(body_bounds.get_center()) + # The reference returns the model bounding sphere centre, not its feet. + var meshes := node.find_children("*", "MeshInstance3D", true, false) + var bounds := AABB() + var found := false + for mesh in meshes: + if mesh.mesh == null or not mesh.is_visible_in_tree(): continue + var box: AABB = mesh.global_transform * mesh.get_aabb() + bounds = bounds.merge(box) if found else box + found = true + return bounds.get_center() if found else node.global_position +func _process(_delta: float) -> void: + var node: Node3D = target.get_ref() if target else null + if not is_instance_valid(node) or not node.is_inside_tree(): + get_parent().queue_free() + return + get_parent().global_transform = node.global_transform * Transform3D(Basis.IDENTITY, offset) diff --git a/project/fx/target_effect_anchor.gd.uid b/project/fx/target_effect_anchor.gd.uid new file mode 100644 index 00000000..f6f6cf73 --- /dev/null +++ b/project/fx/target_effect_anchor.gd.uid @@ -0,0 +1 @@ +uid://bhnn4cqiuego2 diff --git a/project/fx_test.gd b/project/fx_test.gd index 3d1bb626..01976048 100644 --- a/project/fx_test.gd +++ b/project/fx_test.gd @@ -159,6 +159,7 @@ func _test_builder() -> void: "emitter 1 edge sphere uses surface emission") _ck(abs(pm1.spread - 180.0) < 0.01, "OUTER emitter uses radial-velocity fallback spread") _ck(fx._lights.size() == 1, "EffectPlayer built 1 OmniLight3D") + fx.play() fx._process(0.11) _ck(fx._lights[0].visible, "SimpleLight becomes visible after start time") _ck(abs(fx._lights[0].omni_range - 0.06) < 0.02, "SimpleLight range follows TimeEventRange") @@ -171,7 +172,7 @@ func _test_builder() -> void: var bezier_mid: Vector3 = fx._position_at(moving_rows, 1.5) _ck(abs(direct_mid.x - 0.5) < 0.001 and abs(direct_mid.y) < 0.001, "MOVING_TYPE_DIRECT interpolates in metres") - _ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.y - 0.5) < 0.001, + _ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.z + 0.5) < 0.001 and abs(bezier_mid.y) < 0.001, "MOVING_TYPE_BEZIER_CURVE uses previous control point") fx.play() _ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)") diff --git a/project/game_scene.gd b/project/game_scene.gd index f55eb6fd..96bc8a26 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -303,6 +303,7 @@ func setup(m2client: Node, assets_root: String, ) if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"): net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code)) + quickbar.skill_cast_started.connect(_on_skill_cast_started) if quickbar.has_signal("skill_activated"): quickbar.skill_activated.connect(func(sid: int): if skill_fx and player: @@ -835,6 +836,32 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto 9: # WARP —— 传送起点,不是投射物 pass +var _motion_effect_target: WeakRef + +func _on_skill_cast_started(_skill: int, target_vid: int) -> void: + _motion_effect_target = null + var target: Node3D = null + if client and target_vid == int(client.get_main_vid()): + target = player + elif net_world and target_vid != 0: + target = net_world.node_for(target_vid) + if is_instance_valid(target): + _motion_effect_target = weakref(target) + +func _on_local_motion_event_detailed(event: Dictionary) -> void: + # Keep sound/projectile dispatch exactly once, independent of visual routing. + if int(event.type) == 1 and String(event.effect) != "": + _on_local_motion_event(int(event.type), "", String(event.sound), event.pos) + if fx and is_instance_valid(player): + fx.spawn_motion(event, player, _mount) + elif int(event.type) == 10: + _on_local_motion_event(int(event.type), "", String(event.sound), event.pos) + var target: Node3D = _motion_effect_target.get_ref() if _motion_effect_target else null + if fx and is_instance_valid(target): + fx.spawn_target(event, target, _mount) + else: + _on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos) + # 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。 # 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6 func _queue_shot(skill: int) -> void: @@ -936,6 +963,7 @@ func _on_warp(pos: Vector3, same_server: bool) -> void: _place_player_at_net_pos(pos) func _on_world_reset() -> void: + _motion_effect_target = null _main_generation += 1 _main_view_key = "" _main_attempts = 0 @@ -1249,6 +1277,18 @@ func set_entity_model_factory(factory: Callable) -> void: if net_world: net_world.set_model_factory(factory) +# Borrowed-node adapter for the packaged playable test. It is only exposed +# from an explicit test mode; the returned nodes must never be serialized or +# retained across _on_world_reset / AppFlow reconnect. +func get_playable_context() -> Dictionary: + var test_mode := OS.get_environment("MT_TEST_MODE") + if test_mode != "playable" and test_mode != "forest_render": + return {} + return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path, + "net_play": net_play, "net_world": net_world, "world": world, + "player": player, "pc": pc, "hud": hud, "quickbar": quickbar, + "ground_items": ground_items, "skill_fx": skill_fx} + var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试) var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性) @@ -1518,7 +1558,9 @@ func _on_main_set(vid: int) -> void: _model_built = true if net_play: net_play.player_view = pv - if pv.anim and pv.anim.has_signal("motion_event"): + if pv.anim and pv.anim.has_signal("motion_event_detailed"): + pv.anim.motion_event_detailed.connect(_on_local_motion_event_detailed) + elif pv.anim and pv.anim.has_signal("motion_event"): pv.anim.motion_event.connect(_on_local_motion_event) if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state): pc.anim_state.connect(_on_main_anim_state) diff --git a/project/gpu_lod_attachment_test.gd b/project/gpu_lod_attachment_test.gd new file mode 100644 index 00000000..d47bb4a2 --- /dev/null +++ b/project/gpu_lod_attachment_test.gd @@ -0,0 +1,63 @@ +extends SceneTree + +const PlayerView = preload("res://ui/player_view.gd") +var failures: Array[String] = [] +var ghosts_checked := 0 + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + if not ok: + failures.append(message) + printerr("FAIL: ", message) + +func run() -> void: + OS.set_environment("MTGODOT_GPUSKIN", "1") + var cam := Camera3D.new() + root.add_child(cam) + cam.make_current() + for race in 8: + cam.position = Vector3(0, 1, 3) + var pv := PlayerView.new() + root.add_child(pv) + if not pv.build(AssetRoot.path(), race): + check(false, "race %d build failed" % race) + pv.queue_free() + continue + pv.anim.set_process(false) + pv.anim.set("blend_time", 0.0) + pv.anim.set("time", 0.5) + var mesh_node: MeshInstance3D = pv.model.get_node("MeshInstance3D") + var reference_hair: PackedVector3Array = mesh_node.mesh.surface_get_arrays(mesh_node.mesh.get_surface_count() - 1)[Mesh.ARRAY_VERTEX] + for level in [0, 1, 2, 3, 2, 1, 0]: + cam.position = Vector3(0, 1, [3.0, 25.0, 60.0, 110.0][level]) + for frame in 3: + await process_frame + check(int(pv.model.call("get_lod_level")) == level, "race %d did not reach LOD %d" % [race, level]) + var mesh := mesh_node.mesh + var last := mesh.get_surface_count() - 1 + check(String(mesh.surface_get_name(last)).begins_with("hair_"), "race %d LOD %d lost hair" % [race, level]) + var vertices: PackedVector3Array = mesh.surface_get_arrays(last)[Mesh.ARRAY_VERTEX] + check(vertices == reference_hair, "race %d LOD %d changed hair geometry" % [race, level]) + var mat: ShaderMaterial = mesh_node.get_active_material(last) + check(int(mat.get_shader_parameter("mode")) == 2, "race %d LOD %d lost hair cutout" % [race, level]) + check(mat.get_shader_parameter("bones_tex") != null, "race %d LOD %d lost skin matrices" % [race, level]) + var ghost: MeshInstance3D = pv.model.get_node_or_null("LodGhost") + if ghost != null: + ghosts_checked += 1 + var old_mat: ShaderMaterial = ghost.get_active_material(0) + var frozen: Texture2D = old_mat.get_shader_parameter("bones_tex") + check(frozen != mat.get_shader_parameter("bones_tex"), "LOD ghost shares live bone texture") + var before := frozen.get_image().get_data() + pv.anim.set("time", 1.0) + check(before == frozen.get_image().get_data(), "LOD ghost pose changed after animation sample") + check(ghost.custom_aabb.size.length_squared() > 0, "LOD ghost lost posed culling bounds") + for surface in ghost.mesh.get_surface_count(): + check(ghost.get_active_material(surface) != null, "LOD ghost lost surface material") + pv.anim.set("time", 0.5) + pv.queue_free() + await process_frame + check(ghosts_checked > 0, "No LOD fade snapshots were exercised") + print("gpu_lod_attachment_test: failures=", failures.size(), " ghosts=", ghosts_checked) + quit(0 if failures.is_empty() else 1) diff --git a/project/gpu_lod_attachment_test.gd.uid b/project/gpu_lod_attachment_test.gd.uid new file mode 100644 index 00000000..e4a81ed2 --- /dev/null +++ b/project/gpu_lod_attachment_test.gd.uid @@ -0,0 +1 @@ +uid://huxe52dmjvo6 diff --git a/project/gpu_pose_bounds_test.gd b/project/gpu_pose_bounds_test.gd new file mode 100644 index 00000000..9e395833 --- /dev/null +++ b/project/gpu_pose_bounds_test.gd @@ -0,0 +1,46 @@ +extends SceneTree + +const PlayerView = preload("res://ui/player_view.gd") +var failures := 0 + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + for race in 8: + var views: Array = [] + for mode in ["0", "1"]: + OS.set_environment("MTGODOT_GPUSKIN", mode) + var pv := PlayerView.new() + root.add_child(pv) + if not pv.build(AssetRoot.path(), race): + printerr("FAIL: build race ", race) + quit(1) + return + pv.model.set("lod_enabled", false) + pv.anim.set_process(false) + pv.anim.set("blend_time", 0.0) + views.append(pv) + for motion in ["wait", "walk", "run", "attack", "damage", "dead"]: + for i in 2: + OS.set_environment("MTGODOT_GPUSKIN", str(i)) + views[i].set_anim_state(motion) + views[i].anim.set("loop", false) + for fraction in [0.0, 0.25, 0.5, 0.75, 1.0]: + for i in 2: + OS.set_environment("MTGODOT_GPUSKIN", str(i)) + views[i].anim.set("time", float(views[i].anim.call("get_duration")) * fraction) + var cpu: MeshInstance3D = views[0].model.get_node("MeshInstance3D") + var gpu: MeshInstance3D = views[1].model.get_node("MeshInstance3D") + # Read the actual culling box without invoking get_visual_aabb(), + # which refreshes it on demand and would hide the stale-box bug. + var expected: AABB = cpu.get_aabb() + var actual: AABB = gpu.custom_aabb + if not actual.position.is_finite() or not actual.size.is_finite() or not actual.grow(0.02).encloses(expected): + failures += 1 + printerr("FAIL: GPU culling excludes CPU pose: race=%d motion=%s time=%s" % [race, motion, fraction]) + for pv in views: + pv.queue_free() + await process_frame + print("gpu_pose_bounds_test: failures=", failures, " (8 races x 6 motions x 5 samples)") + quit(0 if failures == 0 else 1) diff --git a/project/gpu_pose_bounds_test.gd.uid b/project/gpu_pose_bounds_test.gd.uid new file mode 100644 index 00000000..64c0f236 --- /dev/null +++ b/project/gpu_pose_bounds_test.gd.uid @@ -0,0 +1 @@ +uid://d4asr8c7kwoep diff --git a/project/live_smoke_test.gd b/project/live_smoke_test.gd index 26ca9cde..d9c5be5b 100644 --- a/project/live_smoke_test.gd +++ b/project/live_smoke_test.gd @@ -103,6 +103,12 @@ func _process(_delta: float) -> void: "settle_second": if _seconds_in_state(now) >= SETTLE_SECONDS: _check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态") + var vid := int(_client.get_main_vid()) + _check(vid != 0 and not _client.get_entity(vid).is_empty(), "重连后主角重新建立") + _check(not _client.get_points().is_empty() and _has_method_and_array("get_skills") + and _has_method_and_array("get_quickslots"), "重连后属性/技能/快捷栏可读取") + var scene: Node = _flow.get("_game") + _check(is_instance_valid(scene) and bool(scene.get("_model_built")), "重连后真实角色模型建立") _finish_later() "waiting_login", "waiting_first_game": if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS: @@ -189,7 +195,7 @@ func _run_world_checks() -> void: _fail("主角色缺少 pos_cm") if not _mutations: - _finish_later() + _start_reconnect() return _send_move() @@ -215,6 +221,9 @@ func _send_stop() -> void: func _send_chat() -> void: var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test")) _check(ok, "发送普通聊天包") + _start_reconnect() + +func _start_reconnect() -> void: if not _reconnect: _finish_later() return diff --git a/project/mob_winding_test.gd b/project/mob_winding_test.gd new file mode 100644 index 00000000..b79e9992 --- /dev/null +++ b/project/mob_winding_test.gd @@ -0,0 +1,42 @@ +extends SceneTree +const MobView = preload("res://ui/mob_view.gd") +var failures := 0 +func _init() -> void: + call_deferred("run") +func run() -> void: + for mode in ["0", "1"]: + OS.set_environment("MTGODOT_GPUSKIN", mode) + for race in [101, 110, 20001]: + var view := MobView.new() + if not view.build(AssetRoot.path(), null, race): + failures += 1 + view.free() + continue + root.add_child(view) + view.anim.set_process(false) + view.anim.set_time(0.3) + var signed_area := 0.0 + var total_area := 0.0 + for instance in view.model.find_children("*", "MeshInstance3D", true, false): + for surface in instance.mesh.get_surface_count(): + var arrays: Array = instance.mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var normals: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL] + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + for i in range(0, indices.size(), 3): + var a := indices[i] + var b := indices[i + 1] + var c := indices[i + 2] + var cross := (vertices[b] - vertices[a]).cross(vertices[c] - vertices[a]) + signed_area += cross.dot((normals[a] + normals[b] + normals[c]).normalized()) + total_area += cross.length() + var ratio := signed_area / maxf(total_area, 0.00001) + print("MOB winding race=%d GPU=%s normal_alignment=%f" % [race, mode, ratio]) + # Godot's outward front face is clockwise; geometric cross is -normal. + if ratio >= -0.5: + failures += 1 + printerr("FAIL: NPC/monster faces disagree with outward normals") + view.free() + await process_frame + print("mob_winding_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/mob_winding_test.gd.uid b/project/mob_winding_test.gd.uid new file mode 100644 index 00000000..7b9aee3a --- /dev/null +++ b/project/mob_winding_test.gd.uid @@ -0,0 +1 @@ +uid://dai84hb3pgfqd diff --git a/project/model_render_test.gd b/project/model_render_test.gd index b6343d0b..e8fe7d13 100644 --- a/project/model_render_test.gd +++ b/project/model_render_test.gd @@ -63,6 +63,10 @@ func _arg(name: String, dflt := "") -> String: func run() -> void: root.size = Vector2i(SIZE, SIZE) _bless = "--bless" in OS.get_cmdline_user_args() + if _bless and ("--legacy-winding" in OS.get_cmdline_user_args() or not _arg("race").is_empty()): + printerr("Baseline updates require all eight production races; remove diagnostic/race filters.") + quit(1) + return _out = OS.get_environment("MT_RENDER_OUTPUT") if _out.is_empty(): _out = ProjectSettings.globalize_path("res://../build/rendering/modeltest-%d" @@ -116,7 +120,7 @@ func run() -> void: var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE) f.store_string(JSON.stringify(report, "\t")) f.close() - if _bless: + if _bless and _fail.is_empty(): _write_baseline(results) print("MODELRENDER: baseline written -> ", ProjectSettings.globalize_path(BASELINE)) print("MODELRENDER: ", _out) @@ -135,9 +139,13 @@ func _measure(race: int, cam: Camera3D) -> Dictionary: pv.queue_free() return {} pv.set_anim_state("wait") + # Reproduce the pre-merge CPU index order to audit historical baselines. + if "--legacy-winding" in OS.get_cmdline_user_args(): + pv.model.set("flip_winding", false) await process_frame await process_frame if pv.anim: + pv.anim.set("blend_time", 0.0) pv.anim.call("set_time", 0.5) pv.anim.set_process(false) pv.rotation = Vector3.ZERO @@ -163,6 +171,8 @@ func _measure(race: int, cam: Camera3D) -> Dictionary: feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y) var m := { + "body_sha256": FileAccess.get_sha256(String(pv.model.get("gr2_path"))), + "hair_sha256": FileAccess.get_sha256(String(pv.model.get("hair_gr2"))), "body_gr2": String(pv.model.get("gr2_path")), "hair_gr2": String(pv.model.get("hair_gr2")), "surfaces": total, "hair_surface_first": hair_first, @@ -237,6 +247,9 @@ func _check_baseline(race: int, m: Dictionary, baseline: Dictionary) -> void: _fail.append("race %d: no baseline entry (run with --bless after reviewing the render)" % race) return var b: Dictionary = baseline[key] + for field in ["body_sha256", "hair_sha256"]: + if b.has(field) and b[field] != m[field]: + _fail.append("race %d: %s differs from reviewed asset baseline" % [race, field]) var cover_delta: float = absf(float(m["head_cover"]) - float(b.get("head_cover", 0.0))) if cover_delta > TOL_HEAD_COVER: _fail.append("race %d: head_cover %.4f vs baseline %.4f (delta %.4f > %.3f)" @@ -408,7 +421,8 @@ func _write_baseline(results: Dictionary) -> void: var slim := {} for k in results: var m: Dictionary = results[k] - slim[k] = {"body_gr2": String(m["body_gr2"]).get_file(), + slim[k] = {"body_sha256": m["body_sha256"], "hair_sha256": m["hair_sha256"], + "body_gr2": String(m["body_gr2"]).get_file(), "hair_gr2": String(m["hair_gr2"]).get_file(), "head_cover": m["head_cover"], "silhouette_px": m["silhouette_px"], "head_px": m["head_px"], "hair_mean_lum": m["hair_mean_lum"], diff --git a/project/motion_effect_anchor_test.gd b/project/motion_effect_anchor_test.gd new file mode 100644 index 00000000..9ad60531 --- /dev/null +++ b/project/motion_effect_anchor_test.gd @@ -0,0 +1,61 @@ +extends SceneTree +const Anchor = preload("res://fx/motion_effect_anchor.gd") +const PlayerView = preload("res://ui/player_view.gd") +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var view := PlayerView.new() + check(view.build(AssetRoot.path(), 0), "real warrior builds") + root.add_child(view) + view.position = Vector3(7, 2, -4) + view.rotation.y = 0.7 + view.anim.set_process(false) + view.anim.set_time(0.2) + var effect := Node3D.new() + root.add_child(effect) + var anchor := Anchor.new() + effect.add_child(anchor) + var event := {"attaching": true, "following": true, "independent": false, + "bone": "equip_right_hand", "pos": Vector3(10, 20, 30)} + check(anchor.configure(view, event), "real equip_right_hand resolves") + check(anchor.is_processing(), "following bone updates") + var pose: Dictionary = view.anim.get_effect_bone_pose(event.bone) + if not pose.is_empty(): + var expected: Vector3 = view.model.global_transform * (pose.transform.origin + event.pos) + check(effect.global_position.distance_to(expected) < 0.00001, "bone + actor-local cm offset, then actor rotation") + var before := effect.global_transform + view.anim.set_time(1.0) + anchor.update_anchor() + check(not effect.global_transform.is_equal_approx(before), "anchor uses sampled animation pose") + event.following = false + check(anchor.configure(view, event) and not anchor.is_processing(), "snapshot bone does not follow") + before = effect.global_transform + view.position.x += 3.0 + await process_frame + check(effect.global_transform.is_equal_approx(before), "snapshot remains in world after actor moves") + event.independent = true + event.bone = "missing" + check(anchor.configure(view, event), "independent ignores bone") + check(effect.global_position.distance_to(view.model.global_transform * event.pos) < 0.00001, + "independent offset converts source Z to world up") + event.independent = false + check(not anchor.configure(view, event), "missing bone rejected") + event.attaching = false + check(anchor.configure(view, event) and anchor.is_processing(), "root attachment follows regardless of FollowingEnable") + # Metadata from the real file must survive the file-reader parser and native API. + view.anim.set_anim_path(AssetRoot.path().path_join("PC/ymir work/pc/warrior/skill/gigongcham.msa")) + var events: Array = view.anim.get_events() + check(not events.is_empty(), "real skill events loaded") + if not events.is_empty(): + check(events[0].attaching and events[0].bone == "Bip01 Spine1", "file parser retains bone metadata") + check(String(events[0].effect).contains("/"), "full effect path retained") + effect.free() + view.free() + await process_frame + print("motion_effect_anchor_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/motion_effect_anchor_test.gd.uid b/project/motion_effect_anchor_test.gd.uid new file mode 100644 index 00000000..76034c1e --- /dev/null +++ b/project/motion_effect_anchor_test.gd.uid @@ -0,0 +1 @@ +uid://dmqy41vlghvjy diff --git a/project/native_tree_test.gd b/project/native_tree_test.gd new file mode 100644 index 00000000..5405b18c --- /dev/null +++ b/project/native_tree_test.gd @@ -0,0 +1,107 @@ +extends SceneTree +var failures: Array[String] = [] +var report := {"species": [], "maps": [], "scope": "native static tree geometry/materials; not SpeedTree wind/LOD parity"} +func check(ok: bool, label: String) -> void: + if not ok: + failures.append(label) + printerr("FAIL: " + label) +func _init() -> void: call_deferred("run") +func run() -> void: + var assets := AssetRoot.path() + var directory := assets.path_join("TreeGeometry") + var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("manifest.json"))) + check(manifest.trees.size() == 87, "all 87 extracted species staged") + var sample_mesh: ArrayMesh + for hash in manifest.trees: + var record: Dictionary = manifest.trees[hash] + var path := directory.path_join(record.glb) + check(FileAccess.get_sha256(assets.path_join(record.source)) == hash, "SPT provenance " + record.source) + check(FileAccess.get_sha256(path) == record.glb_sha256, "GLB provenance " + record.source) + var state := GLTFState.new() + var doc := GLTFDocument.new() + check(doc.append_from_file(path, state) == OK, "GLB loads " + record.source) + if state.get_meshes().size() != 1: + check(false, "one native combined mesh") + continue + var mesh: ArrayMesh = state.get_meshes()[0].get_mesh().get_mesh() + check(mesh.get_surface_count() == int(record.primitives), "all branch/frond/leaf surfaces retained") + var masked := 0 + var vertices := 0 + for surface in mesh.get_surface_count(): + var arrays := mesh.surface_get_arrays(surface) + vertices += arrays[Mesh.ARRAY_VERTEX].size() + var material: StandardMaterial3D = mesh.surface_get_material(surface) + check(material != null and material.albedo_texture != null, "real texture retained") + if material and material.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR: + masked += 1 + check(material.cull_mode == BaseMaterial3D.CULL_DISABLED, "leaf cards double-sided") + check(is_equal_approx(material.alpha_scissor_threshold, 0.3), "native alpha cutoff retained") + check(vertices > 0 and mesh.get_aabb().size.is_finite(), "finite native geometry") + report.species.append({"source": record.source, "vertices": vertices, "surfaces": mesh.get_surface_count(), "masked": masked, "height_m": mesh.get_aabb().size.y}) + if String(record.source).ends_with("b1_beech_rt2.spt"): sample_mesh = mesh + for map in ["OutdoorA1/metin2_map_a1", "OutdoorA3/metin2_map_a3", "OutdoorB1/metin2_map_b1", + "OutdoorB3/metin2_map_b3", "OutdoorC1/metin2_map_c1", "OutdoorC3/metin2_map_c3"]: + var world := Metin2World.new() + world.auto_load = false + world.env_enabled = false + world.assets_root = assets + world.map_path = map + root.add_child(world) + check(world.load_map(), "map loads " + map) + var stats: Dictionary = world.get_load_report() + check(stats.native_trees_placed > 0 and stats.proxy_trees_placed == 0, "all placed trees native " + map) + check(stats.native_trees_placed == stats.trees_placed, "tree placement count preserved") + for node in world.find_children("*", "MultiMeshInstance3D", true, false): + if node.has_meta("tree_geometry"): + check(node.get_meta("tree_geometry") == "native_spt", "renderer uses native path") + check(node.multimesh.mesh.get_meta("source_sha256", "") != "", "mesh provenance retained") + report.maps.append({"map": map, "native": stats.native_trees_placed, "proxy": stats.proxy_trees_placed}) + world.free() + await process_frame + var output := OS.get_environment("MT_TREE_OUTPUT") + if output == "": output = ProjectSettings.globalize_path("res://../build/rendering/native-trees") + DirAccess.make_dir_recursive_absolute(output) + if sample_mesh and DisplayServer.get_name() != "headless": await render_sample(sample_mesh, output) + report["failures"] = failures + FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t")) + print("native_tree_test: failures=%d report=%s" % [failures.size(), output]) + quit(1 if not failures.is_empty() else 0) +func render_sample(mesh: ArrayMesh, output: String) -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(640, 640) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var environment := WorldEnvironment.new() + environment.environment = Environment.new() + environment.environment.background_mode = Environment.BG_COLOR + environment.environment.background_color = Color(0.12, 0.16, 0.23) + environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.environment.ambient_light_color = Color.WHITE + environment.environment.ambient_light_energy = 0.8 + viewport.add_child(environment) + var instance := MeshInstance3D.new() + instance.mesh = mesh + viewport.add_child(instance) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-40, -30, 0) + viewport.add_child(light) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 28 + viewport.add_child(camera) + for angle in [0.0, PI / 2]: + camera.position = Vector3(sin(angle) * 35, 12, cos(angle) * 35) + camera.look_at(Vector3(0, 10, 0)) + await process_frame + await RenderingServer.frame_post_draw + var image := viewport.get_texture().get_image() + var background := image.get_pixel(0, 0) + var visible := 0 + for y in 640: + for x in 640: + if image.get_pixel(x, y) != background: visible += 1 + check(visible > 5000 and visible < 200000, "native tree silhouette is visible without opaque atlas rectangle") + check(image.save_png(output.path_join("beech-%d.png" % int(rad_to_deg(angle)))) == OK, "native tree screenshot") + viewport.free() + await process_frame diff --git a/project/native_tree_test.gd.uid b/project/native_tree_test.gd.uid new file mode 100644 index 00000000..2cb6f8fe --- /dev/null +++ b/project/native_tree_test.gd.uid @@ -0,0 +1 @@ +uid://brr0ihehpik0y diff --git a/project/net_play.gd b/project/net_play.gd index 53979c2a..b85f13df 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -1268,6 +1268,16 @@ func _change_target_to_picked_instance(vid: int) -> bool: _refresh_target_hud() return true +# Public test/automation adapter for the same target path used by a real +# pointer click. It keeps all combat eligibility checks and sends the normal +# CG_TARGET through _change_target_to_picked_instance; it does not synthesize +# a hit or alter EntityStore state. +func select_target(vid: int) -> bool: + if _observer_mode or client == null or not client.has_method("is_in_game") or not client.is_in_game(): + return false + _attack_cd = 0.0 + return _change_target_to_picked_instance(vid) + # --- §3.4 扇形 / 圆形多目标 fly-targeting(PythonPlayerSkill.cpp:680-746)---------- # 主角 → 某点的水平方位角(度)。扇形过滤 / 随机点抖动只比较角差,与坐标系无关, diff --git a/project/package_render_test.gd b/project/package_render_test.gd index f99c1927..b88a62c3 100644 --- a/project/package_render_test.gd +++ b/project/package_render_test.gd @@ -49,6 +49,7 @@ func _run() -> void: _fail.append("资源缺失:%s" % p) if _fail.is_empty(): + report["effects"] = await _check_effect_resources(root) report["scene"] = await _build_and_measure() else: report["scene"] = {"skipped": "前置检查已失败,不再装场景"} @@ -65,6 +66,34 @@ func _run() -> void: print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL") get_tree().quit(0 if _fail.is_empty() else 1) +func _check_effect_resources(assets: String) -> Dictionary: + var dds_path := assets.path_join("PC/ymir work/pc/shaman/effect/shamanblackcore.dds") + var decoded := preload("res://ui/ui_assets.gd").load_dds_image(dds_path) + var dds_ok := decoded != null and not decoded.is_empty() + if not dds_ok: + _fail.append("包内 16 位技能 DDS 解码失败") + var mse_path := assets.path_join("PC/ymir work/pc/shaman/effect/noejeon_4_blow.mse") + var spec := preload("res://fx/mse.gd").new().parse_file(mse_path) + var effect := preload("res://fx/effect_player.gd").new() + effect.build(spec, assets) + add_child(effect) + # ParticleProcessMaterial shader variants are compiled on a deferred frame. + # Exercise a real lifecycle instead of freeing uninitialized GPU emitters. + await get_tree().process_frame + if DisplayServer.get_name() != "headless": + await RenderingServer.frame_post_draw + var special_materials := 0 + for emitter in effect._emitters: + if emitter.material_override is ShaderMaterial: + special_materials += 1 + if special_materials == 0: + _fail.append("包内真实技能未装配专用颜色材质") + effect.free() + await get_tree().process_frame + return {"dds_decoded": dds_ok, "special_materials": special_materials, + "dds_sha256": FileAccess.get_sha256(dds_path), "mse_sha256": FileAccess.get_sha256(mse_path), + "scope": "bundled DDS decode and real MSE material assembly; not skill pixels"} + # 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。 func _env() -> Dictionary: var cands := {} @@ -142,6 +171,10 @@ func _build_and_measure() -> Dictionary: _fail.append("地图没装出来(灰底虚空)") if surfaces == 0: _fail.append("玩家节点下 0 个 surface") + if scene.world: + var trees: Dictionary = scene.world.get_load_report() + if int(trees.get("native_trees_placed", 0)) <= 0 or int(trees.get("proxy_trees_placed", -1)) != 0: + _fail.append("真实树木缺失或仍有代理回退:%s" % trees) if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX: _fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX]) diff --git a/project/particle_exit_test.gd b/project/particle_exit_test.gd new file mode 100644 index 00000000..b7ba30db --- /dev/null +++ b/project/particle_exit_test.gd @@ -0,0 +1,39 @@ +# Export-template reproducer: no maps, models, assets or network required. +extends Node +var held: Array[ParticleProcessMaterial] = [] +func _ready() -> void: + call_deferred("run") +func run() -> void: + var mode := OS.get_environment("MT_PARTICLE_EXIT_CASE") + for i in 8: + make_particle(mode, i) + for i in 5: await get_tree().process_frame + held.clear() + for i in 5: await get_tree().process_frame + print("PARTICLE_EXIT PASS case=", mode) + get_tree().quit() +func make_particle(mode: String, index: int) -> void: + if mode == "empty": return + var material := ParticleProcessMaterial.new() + if mode in ["gravity", "scale", "shape", "held", "restored"]: + if mode == "gravity": material.gravity = Vector3(0, -1, 0) + if mode == "scale": material.scale_min = 0.5 + if mode in ["shape", "held", "restored"]: material.emission_shape = index % 3 + material.get_rid() + if mode == "restored": + material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT + material.get_rid() + if mode == "held": held.append(material) + return + if mode == "primed": material.get_rid() + if mode != "default": + material.gravity = Vector3(0, -1, 0) + material.emission_shape = index % 3 + material.scale_min = 0.5 + material.get_rid() + if mode == "emitter": + var emitter := GPUParticles3D.new() + emitter.emitting = false + emitter.process_material = material + add_child(emitter) + emitter.free() diff --git a/project/particle_exit_test.gd.uid b/project/particle_exit_test.gd.uid new file mode 100644 index 00000000..c4b7c584 --- /dev/null +++ b/project/particle_exit_test.gd.uid @@ -0,0 +1 @@ +uid://d16lqtrehlaet diff --git a/project/playable_adapter_test.gd b/project/playable_adapter_test.gd new file mode 100644 index 00000000..16a1b6e2 --- /dev/null +++ b/project/playable_adapter_test.gd @@ -0,0 +1,56 @@ +extends SceneTree + +const NetPlay = preload("res://net_play.gd") + +class FakeClient extends Node: + signal entity_main_set(vid: int) + signal entity_moved(vid: int) + signal points_changed(points: Dictionary) + signal vitals_changed(vid: int) + signal target_info(vid: int, hp_percent: int) + signal entity_despawned(vid: int) + signal entity_dead(vid: int) + var in_game := false + var target := -1 + var entities := {} + func is_in_game() -> bool: return in_game + func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) + func set_target(vid: int) -> bool: + target = vid + return true + +class FakePlayerController extends Node: + signal target_selected(node: Node3D) + signal moved(pos: Vector3) + signal anim_state(state: String) + var player: Node3D + +var failures := 0 + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + message) + +func run() -> void: + var client := FakeClient.new() + var pc := FakePlayerController.new() + var nw := Node.new() + root.add_child(client) + root.add_child(pc) + root.add_child(nw) + var play := NetPlay.new() + root.add_child(play) + play.setup(client, pc, nw) + client.entities[10] = {"vid": 10, "kind": 2, "race": 2301, "dead": false, "hp": 100, "max_hp": 100} + check(not play.select_target(10), "target selection is blocked while disconnected") + client.in_game = true + check(play.select_target(10), "target selection uses production target path") + check(client.target == 10, "target selection sends the selected VID") + check(not play.select_target(999), "missing target is rejected") + check(client.target == 0, "invalid target clears server target") + print("playable_adapter_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/playable_config_gate.gd b/project/playable_config_gate.gd new file mode 100644 index 00000000..63086f69 --- /dev/null +++ b/project/playable_config_gate.gd @@ -0,0 +1,14 @@ +extends SceneTree + +const Config = preload("res://testing/playable_config.gd") + +func _init() -> void: + var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG") + var result := Config.load_file(path) + if not result.ok: + for error in result.errors: + printerr("CONFIG: " + String(error)) + quit(2) + return + print("PLAYABLE CONFIG: PASS " + path) + quit(0) diff --git a/project/playable_harness_test.gd b/project/playable_harness_test.gd new file mode 100644 index 00000000..d7c745bb --- /dev/null +++ b/project/playable_harness_test.gd @@ -0,0 +1,50 @@ +extends SceneTree + +const Config = preload("res://testing/playable_config.gd") +const Report = preload("res://testing/playable_report.gd") + +var failures := 0 + +func _init() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + message) + +func run() -> void: + var example_path := ProjectSettings.globalize_path("res://../test/playable/scenario.example.json") + var example := Config.load_file(example_path) + check(not example.ok, "empty example configuration is rejected") + check(example.errors.size() > 0, "invalid configuration has reasons") + var valid := { + "schema_version": 1, "scenario_id": "harness", "protocol": "classic", + "server": {"server_index": 0, "channel_index": 0}, "character_slot": 0, + "map_key": "OutdoorA1/metin2_map_a1", "waypoints_cm": [[454600, 934000]], + "allowed_mob_vnums": [101], "allowed_drop_vnums": [19], + "skill_cases": [{"case_id": "skill-1", "skill_id": 1, "required_evidence": ["damage"]}], + "resolution": [1280, 720], "loops": 1, "timeout_seconds": 120, + } + check(Config.validate(valid).ok, "complete configuration is accepted") + var secret := valid.duplicate(true) + secret["password"] = "must-be-rejected" + check(not Config.validate(secret).ok, "credentials in configuration are rejected") + + var report := Report.new() + report.begin("harness-run", "playable", valid) + report.add_case("CONFIG-01", "PASS", "validated") + report.add_case("EVENT-01", "PASS", "received") + var finished := report.finish() + check(finished.status == "PASS", "all passing cases produce client PASS") + check(finished.coverage.required == 2 and finished.coverage.passed == 2, "coverage is exact") + var blocked := Report.new() + blocked.begin("blocked-run", "playable", valid) + blocked.add_case("FIXTURE-01", "BLOCKED", "fixture_not_ready") + check(blocked.finish().status == "BLOCKED", "blocked fixture cannot become PASS") + var out := "user://playable-harness-report.json" + check(report.write(out), "report can be serialized") + var json: Variant = JSON.parse_string(FileAccess.get_file_as_string(out)) + check(json is Dictionary and json.get("run_id", "") == "harness-run", "serialized report is readable") + print("playable_harness_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/playable_live_test.gd b/project/playable_live_test.gd new file mode 100644 index 00000000..b8fc503b --- /dev/null +++ b/project/playable_live_test.gd @@ -0,0 +1,373 @@ +extends Node + +## 首个 Mac 内测的真实联网闭环。 +## +## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController, +## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT +## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只做登录/进场 +## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。 + +const Config = preload("res://testing/playable_config.gd") +const Report = preload("res://testing/playable_report.gd") +const Probe = preload("res://testing/playable_probe.gd") + +const WAIT_WORLD_SECONDS := 60.0 +const MOVE_SECONDS := 20.0 +const TARGET_SECONDS := 30.0 +const ATTACK_SECONDS := 90.0 +const DROP_SECONDS := 30.0 +const PICKUP_SECONDS := 15.0 + +var _flow: Node +var _client: Node +var _config: Dictionary +var _report: RefCounted +var _probe: Node +var _report_path := "" +var _events_path := "" +var _state := "boot" +var _state_since := 0.0 +var _started := 0.0 +var _finished := false +var _allow_gameplay := false +var _entered_count := 0 +var _main_vid := 0 +var _server_move_seen := false +var _waypoint_index := 0 +var _expected_position := Vector3.ZERO +var _target_vid := 0 +var _target_initial_hp := -1 +var _target_damaged := false +var _target_dead := false +var _drop_vid := 0 +var _drop_vnum := 0 +var _pickup_before := 0 +var _cases := {} + +func setup(flow: Node, m2client: Node, config_path := "") -> void: + _flow = flow + _client = m2client + _started = Time.get_ticks_msec() / 1000.0 + _state_since = _started + _allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1" + _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges() + if _report_path.is_empty(): + _report_path = "user://playable-client-report.json" + _events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges() + if _events_path.is_empty(): + _events_path = _report_path.get_base_dir().path_join("events.jsonl") + var path := config_path if not config_path.is_empty() else OS.get_environment("MT_PLAYABLE_CONFIG") + var loaded := Config.load_file(path) + _report = Report.new() + _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {}) + if not loaded.ok: + _case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors)) + _finish_later() + return + _config = loaded.config + _case("CONFIG-01", "PASS", "validated") + if _client == null: + _case("NET-BOOT-01", "FAIL", "M2Client was not created") + _finish_later() + return + _probe = Probe.new() + _probe.name = "PlayableProbe" + add_child(_probe) + _probe.setup(_flow, _client) + _probe.observed.connect(_on_observed) + _bind("char_list", Callable(self, "_on_char_list")) + _bind("entered_game", Callable(self, "_on_entered_game")) + _bind("disconnected", Callable(self, "_on_disconnected")) + _bind("entity_moved", Callable(self, "_on_entity_moved")) + _bind("damage", Callable(self, "_on_damage")) + _bind("entity_dead", Callable(self, "_on_entity_dead")) + _bind("ground_item_added", Callable(self, "_on_ground_item_added")) + _bind("ground_item_removed", Callable(self, "_on_ground_item_removed")) + _bind("item_picked_up", Callable(self, "_on_item_picked_up")) + _bind("inventory_changed", Callable(self, "_on_inventory_changed")) + _set_state("WAIT_LOGIN") + _record("harness_started", {"allow_gameplay": _allow_gameplay}) + +func _process(_delta: float) -> void: + if _finished: + return + var now := Time.get_ticks_msec() / 1000.0 + if now - _started > float(_config.get("timeout_seconds", 120)): + _fail_current("overall timeout in state %s" % _state) + _finish_later() + return + match _state: + "WAIT_LOGIN": + if _entered_count > 0: + _set_state("WAIT_WORLD") + elif now - _state_since > 30.0: + _fail_case("NET-LOGIN-01", "login/character selection timed out") + _finish_later() + "WAIT_WORLD": + var snap: Dictionary = _flow.get_playable_snapshot() if _flow and _flow.has_method("get_playable_snapshot") else {} + if bool(snap.get("scene_ready", false)): + _case("NET-LOGIN-01", "PASS", "character list received") + _case("NET-SELECT-01", "PASS", "entered_game received") + _case("NET-WORLD-01", "PASS", "game scene and map are ready") + _main_vid = int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0 + if _main_vid <= 0: + _fail_case("NET-WORLD-02", "main VID is invalid") + _finish_later() + elif not _allow_gameplay: + _case("GAMEPLAY-ALLOW-01", "BLOCKED", "--allow-gameplay was not supplied") + _finish_later() + else: + _begin_move() + elif now - _state_since > WAIT_WORLD_SECONDS: + _fail_case("NET-WORLD-01", "scene did not become ready") + _finish_later() + "MOVE": + if _move_reached(): + if _server_move_seen: + _case("NET-MOVE-%02d" % (_waypoint_index + 1), "PASS", "server entity_moved observed") + _waypoint_index += 1 + if _waypoint_index >= _config.waypoints_cm.size(): + _begin_target() + else: + _send_waypoint() + else: + _block_current("local arrival without server movement evidence") + _finish_later() + elif now - _state_since > MOVE_SECONDS: + _fail_current("waypoint not reached") + _finish_later() + "TARGET": + if _target_vid > 0: + _case("NET-TARGET-01", "PASS", "allowed live monster selected") + _begin_attack() + elif now - _state_since > TARGET_SECONDS: + _fail_case("NET-TARGET-01", "no allowed live monster found") + _finish_later() + "ATTACK": + if _target_dead or (_target_damaged and _target_hp_decreased()): + if _client.has_method("get_entity") and not _client.get_entity(_target_vid).is_empty(): + _case("NET-ATTACK-01", "PASS", "server damage/death evidence observed") + else: + _block_current("target result arrived after entity removal") + _stop_attack() + _set_state("DROP") + elif now - _state_since > ATTACK_SECONDS: + _stop_attack() + _fail_case("NET-ATTACK-01", "no server damage or death result") + _finish_later() + "DROP": + if _drop_vid > 0: + _case("NET-DROP-01", "PASS", "allowed ground item added by server") + _begin_pickup() + elif now - _state_since > DROP_SECONDS: + _block_current("deterministic drop fixture was not observed") + _finish_later() + "PICKUP": + if _pickup_succeeded(): + _case("NET-PICKUP-01", "PASS", "item_picked_up and inventory delta observed") + _case("NET-EXIT-01", "PASS", "client test state completed") + _finish_later() + elif now - _state_since > PICKUP_SECONDS: + _fail_case("NET-PICKUP-01", "pickup result did not include inventory delta") + _finish_later() + +func _bind(signal_name: String, callback: Callable) -> void: + if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback): + _client.connect(signal_name, callback) + +func _set_state(next: String) -> void: + _state = next + _state_since = Time.get_ticks_msec() / 1000.0 + _record("state", {"name": next}) + +func _on_observed(kind: String, payload: Dictionary) -> void: + _record(kind, payload) + +func _record(kind: String, payload := {}) -> void: + if _report == null: + return + var data: Dictionary = payload.duplicate(true) + data["monotonic_us"] = Time.get_ticks_usec() + data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID") + data["case_id"] = _state + _report.add_event(data, _events_path) + +func _on_char_list(characters: Array) -> void: + _record("char_list", {"count": characters.size()}) + var slot := int(_config.get("character_slot", -1)) + var found := false + for character in characters: + if int(character.get("index", -1)) == slot: + found = true + break + if not found: + _fail_case("NET-LOGIN-01", "configured character slot is absent") + _finish_later() + +func _on_entered_game() -> void: + _entered_count += 1 + _record("entered_game", {"count": _entered_count}) + +func _on_disconnected(reason: String) -> void: + _record("disconnected", {"reason": reason}) + if _state != "WAIT_LOGIN" and _state != "WAIT_WORLD": + _fail_current("unexpected disconnect: %s" % reason) + _finish_later() + +func _on_entity_moved(vid: int) -> void: + if vid == _main_vid: + _server_move_seen = true + _record("main_entity_moved", {"vid": vid}) + +func _on_damage(vid: int, amount: int, flag: int) -> void: + if vid == _target_vid and amount > 0: + _target_damaged = true + _record("target_damage", {"vid": vid, "amount": amount, "flag": flag}) + +func _on_entity_dead(vid: int) -> void: + if vid == _target_vid: + _target_dead = true + _record("target_dead", {"vid": vid}) + +func _on_ground_item_added(item: Dictionary) -> void: + var vnum := int(item.get("vnum", 0)) + if _state == "DROP" and vnum in _config.get("allowed_drop_vnums", []): + _drop_vid = int(item.get("vid", 0)) + _drop_vnum = vnum + _record("allowed_drop", {"vid": _drop_vid, "vnum": vnum}) + +func _on_ground_item_removed(vid: int) -> void: + _record("ground_item_removed", {"vid": vid}) + +func _on_item_picked_up(vnum: int, count: int, source: String) -> void: + if _state == "PICKUP" and vnum == _drop_vnum: + _record("item_picked_up", {"vnum": vnum, "count": count, "source": source}) + +func _on_inventory_changed(window: int, cell: int) -> void: + _record("inventory_changed", {"window": window, "cell": cell}) + +func _begin_move() -> void: + _waypoint_index = 0 + _send_waypoint() + +func _send_waypoint() -> void: + var point: Array = _config.waypoints_cm[_waypoint_index] + var net := Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01) + _expected_position = MapCoord.to_world(net) + var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} + var controller: Node = context.get("pc", null) + _server_move_seen = false + if controller == null or not controller.has_method("walk_to"): + _fail_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PlayerController.walk_to is unavailable") + _finish_later() + return + controller.walk_to(_expected_position) + _set_state("MOVE") + _record("move_requested", {"waypoint_index": _waypoint_index, "x_cm": point[0], "y_cm": point[1]}) + +func _move_reached() -> bool: + var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} + var player: Node3D = context.get("player", null) + return is_instance_valid(player) and player.global_position.distance_to(_expected_position) <= 1.0 + +func _begin_target() -> void: + _target_vid = 0 + var allowed: Array = _config.get("allowed_mob_vnums", []) + var best_distance := INF + var player_pos := Vector3.ZERO + var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} + var player: Node3D = context.get("player", null) + if is_instance_valid(player): + player_pos = player.global_position + for entity in _client.get_entities(): + var race := int(entity.get("race", 0)) + var kind := int(entity.get("kind", entity.get("ch_type", -1))) + if race not in allowed or bool(entity.get("dead", false)) or kind != 2: + continue + var pos: Variant = entity.get("pos", null) + var distance := 0.0 + if pos is Vector3: + distance = player_pos.distance_to(MapCoord.to_world(pos)) + if distance < best_distance: + best_distance = distance + _target_vid = int(entity.get("vid", 0)) + if _target_vid > 0: + var net_play: Node = context.get("net_play", null) + if net_play == null or not net_play.has_method("select_target") or not net_play.select_target(_target_vid): + _target_vid = 0 + _set_state("TARGET") + +func _begin_attack() -> void: + var entity: Dictionary = _client.get_entity(_target_vid) + _target_initial_hp = int(entity.get("hp", -1)) + _target_damaged = false + _target_dead = false + var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} + var net_play: Node = context.get("net_play", null) + if net_play == null or not net_play.has_method("set_attack_key"): + _fail_case("NET-ATTACK-01", "NetPlay.set_attack_key is unavailable") + _finish_later() + return + net_play.set_attack_key(true) + _set_state("ATTACK") + +func _stop_attack() -> void: + var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} + var net_play: Node = context.get("net_play", null) + if net_play and net_play.has_method("set_attack_key"): + net_play.set_attack_key(false) + +func _target_hp_decreased() -> bool: + if not _client.has_method("get_entity"): + return false + var e: Dictionary = _client.get_entity(_target_vid) + return not e.is_empty() and _target_initial_hp > 0 and int(e.get("hp", _target_initial_hp)) < _target_initial_hp + +func _begin_pickup() -> void: + _pickup_before = _inventory_count(_drop_vnum) + var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} + var net_play: Node = context.get("net_play", null) + if net_play == null or not net_play.has_method("pick_ground_item"): + _fail_case("NET-PICKUP-01", "NetPlay.pick_ground_item is unavailable") + _finish_later() + return + net_play.pick_ground_item(_drop_vid) + _set_state("PICKUP") + +func _inventory_count(vnum: int) -> int: + if not _client.has_method("get_inventory"): + return -1 + var total := 0 + for item in _client.get_inventory(): + if int(item.get("vnum", 0)) == vnum: + total += int(item.get("count", 0)) + return total + +func _pickup_succeeded() -> bool: + return _drop_vnum > 0 and _inventory_count(_drop_vnum) > _pickup_before + +func _case(case_id: String, status: String, reason: String) -> void: + if _cases.has(case_id): + return + _cases[case_id] = true + _report.add_case(case_id, status, reason) + +func _fail_case(case_id: String, reason: String) -> void: + _case(case_id, "FAIL", reason) + +func _block_current(reason: String) -> void: + _case("BLOCK-%s" % _state, "BLOCKED", reason) + +func _fail_current(reason: String) -> void: + _fail_case("FAIL-%s" % _state, reason) + +func _finish_later() -> void: + if _finished: + return + _finished = true + _stop_attack() + _report.write(_report_path) + call_deferred("_quit") + +func _quit() -> void: + get_tree().quit(0) diff --git a/project/race_motion_assembly_test.gd b/project/race_motion_assembly_test.gd new file mode 100644 index 00000000..fbd0bcd4 --- /dev/null +++ b/project/race_motion_assembly_test.gd @@ -0,0 +1,47 @@ +extends SceneTree + +const PlayerView = preload("res://ui/player_view.gd") +const EquipModel = preload("res://ui/equip_model.gd") +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + if not ok: + failures.append(message) + printerr(message) + +func run() -> void: + for race in 8: + var pv := PlayerView.new() + root.add_child(pv) + if not pv.build(AssetRoot.path(), race): + check(false, "race %d failed to build" % race) + pv.queue_free() + continue + pv.anim.set_process(false) + pv.anim.set("blend_time", 0.0) + var equip := EquipModel.new() + pv.add_child(equip) + equip.setup_remote(null, func() -> Node: return pv, AssetRoot.path(), race, [0, 0, 0, 0]) + # MSM is an independent asset authority for the equipped body's gender. + var body_dir := String(pv.model.get("gr2_path")).get_base_dir().to_lower() + check(body_dir == pv.motion_dir.get_base_dir().to_lower(), + "race %d: MSM body and animation directories differ" % race) + check(String(pv.model.get("hair_gr2")).to_lower().begins_with(body_dir + "/"), + "race %d: hair is from a different resource family" % race) + for motion in ["wait", "walk", "run", "attack", "damage", "dead"]: + pv.set_anim_state(motion) + check(String(pv.anim.get("anim_path")).get_file() == motion + ".msa", + "race %d: missing %s motion" % [race, motion]) + for fraction in [0.0, 0.5, 1.0]: + pv.anim.set("time", float(pv.anim.call("get_duration")) * fraction) + for mi in pv.model.find_children("*", "MeshInstance3D", true, false): + var bounds: AABB = mi.get_aabb() + check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0, + "race %d: invalid bounds in %s" % [race, motion]) + pv.queue_free() + await process_frame + print("race_motion_assembly_test: failures=", failures.size(), " (8 races, 6 motions, 3 samples)") + quit(0 if failures.is_empty() else 1) diff --git a/project/race_motion_assembly_test.gd.uid b/project/race_motion_assembly_test.gd.uid new file mode 100644 index 00000000..7aa08a60 --- /dev/null +++ b/project/race_motion_assembly_test.gd.uid @@ -0,0 +1 @@ +uid://tevl03ejjnln diff --git a/project/rendering_scenario_test.gd b/project/rendering_scenario_test.gd new file mode 100644 index 00000000..df11cd3c --- /dev/null +++ b/project/rendering_scenario_test.gd @@ -0,0 +1,129 @@ +# Offline real-resource stress/viewport report; NOT 40250 visual parity or +# server reconnect acceptance. Run with a renderer, --quit-after 3600. +extends SceneTree +const GameScene = preload("res://game_scene.gd") +const Fixtures = preload("res://gamescene_test.gd") +const PlayerView = preload("res://ui/player_view.gd") +const MobView = preload("res://ui/mob_view.gd") +class ScenarioClient extends Fixtures.FakeClient: + signal warp(pos: Vector3, same_server: bool) + signal world_reset() +var failures: Array[String] = [] +var output := "" +func check(ok: bool, label: String) -> void: + if not ok: + failures.append(label) + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var run_start := Time.get_ticks_msec() + var sample_frames := 90 + if OS.has_environment("MT_RENDER_SAMPLE_FRAMES"): + sample_frames = clampi(int(OS.get_environment("MT_RENDER_SAMPLE_FRAMES")), 90, 36000) + output = OS.get_environment("MT_RENDER_OUTPUT") + if output == "": + output = ProjectSettings.globalize_path("res://../build/rendering/scenarios-followup") + DirAccess.make_dir_recursive_absolute(output) + root.size = Vector2i(1280, 720) + var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity", + "engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [], + "sample_frames": sample_frames, "model_builds": []} + var client := ScenarioClient.new() + root.add_child(client) + var scene := GameScene.new() + root.add_child(scene) + await scene.setup(client, AssetRoot.path(), "OutdoorA1/metin2_map_a1") + client.main = 1000 + client.ents[1000] = {"vid": 1000, "name": "Scenario", "race": 0, "pos_cm": Vector2(454600, 934000), + "pos": Vector3(4546, 0, -9340), "is_main": true, "func": 0, "moving": false, + "angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false} + for i in 30: await process_frame + check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded") + var centre: Vector3 = scene.player.position + for mode in ["0", "1"]: + OS.set_environment("MTGODOT_GPUSKIN", mode) + var crowd := Node3D.new() + scene.add_child(crowd) + for count in [1, 8, 16]: + while crowd.get_child_count() < count: + var index := crowd.get_child_count() + var view := PlayerView.new() + var build_start := Time.get_ticks_usec() + check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8)) + report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, + "build_ms": (Time.get_ticks_usec() - build_start) / 1000.0, + "note": "synchronous build only; OS/shader caches may already be warm"}) + crowd.add_child(view) + view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2) + view.position.y = scene.world.sample_height(view.position.x, view.position.z) + view.set_anim_state("run") + for i in 12: await process_frame + var times: Array[float] = [] + var switches: Array[float] = [] + var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC) + var previous := Time.get_ticks_usec() + for i in sample_frames: + if i > 0 and i % 30 == 0: + var switch_start := Time.get_ticks_usec() + var state: String = ["run", "attack", "wait"][(i / 30) % 3] + for view in crowd.get_children(): view.set_anim_state(state) + switches.append((Time.get_ticks_usec() - switch_start) / 1000.0) + await process_frame + var now := Time.get_ticks_usec() + times.append((now - previous) / 1000.0) + previous = now + for view in crowd.get_children(): + var bounds: AABB = view.model.get_visual_aabb() + check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0, + "finite posed bounds after run/attack/wait blend") + times.sort() + switches.sort() + report.samples.append({"gpu_skin": mode == "1", "extra_players": count, + "median_ms": times[sample_frames / 2], "p95_ms": times[int((sample_frames - 1) * 0.95)], "max_ms": times[-1], + "motion_switches": switches.size(), "motion_switch_max_ms": switches[-1], + "memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC), + "clip_cache": Metin2AnimPlayer.get_clip_cache_stats(), + "note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames}) + print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d max_ms=%.3f" % [mode, count, sample_frames, times[-1]]) + crowd.free() + await process_frame + OS.set_environment("MTGODOT_GPUSKIN", "0") + var mobs := Node3D.new() + scene.add_child(mobs) + for race in [101, 110, 20001]: + var view := MobView.new() + if not view.build(AssetRoot.path(), null, race): + check(false, "NPC/monster build race=%d" % race) + view.free() + continue + mobs.add_child(view) + view.position = centre + Vector3(mobs.get_child_count() * 2, 0, 3) + view.position.y = scene.world.sample_height(view.position.x, view.position.z) + view.set_anim_state("wait") + check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry") + for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]: + root.size = size + for i in 5: await process_frame + if scene.quickbar and scene.quickbar._root: + var rect: Rect2 = scene.quickbar._root.get_global_rect() + check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size) + if DisplayServer.get_name() != "headless": + await RenderingServer.frame_post_draw + check(root.get_texture().get_image().save_png(output.path_join("scene-%dx%d.png" % [size.x, size.y])) == OK, "screenshot saved") + # Late server location update exercises the production map-correction path. + client.ents[1000].pos_cm = Vector2(959109, 269267) + client.ents[1000].pos = Vector3(9591.09, 0, -2692.67) + client.world_reset.emit() + client.warp.emit(client.ents[1000].pos, false) + for i in 60: await process_frame + check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction") + report["final_map"] = scene.map_path + scene.free() + client.free() + await process_frame + report["failures"] = failures + report["duration_seconds"] = (Time.get_ticks_msec() - run_start) / 1000.0 + FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t")) + print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output]) + quit(1 if not failures.is_empty() else 0) diff --git a/project/rendering_scenario_test.gd.uid b/project/rendering_scenario_test.gd.uid new file mode 100644 index 00000000..376260ec --- /dev/null +++ b/project/rendering_scenario_test.gd.uid @@ -0,0 +1 @@ +uid://d36j4r2snd4uv diff --git a/project/skill_test.gd b/project/skill_test.gd index ab631425..30709b86 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -266,6 +266,8 @@ func _run() -> void: var qb: Node = Quickbar.new() get_root().add_child(qb) qb.setup(fc, st, ui, func() -> Node: return pl) + var cast_targets: Array = [] + qb.skill_cast_started.connect(func(sid: int, vid: int): cast_targets.append([sid, vid])) qb.assign(0, "skill", 1) _ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon") _ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available") @@ -274,12 +276,24 @@ func _run() -> void: _ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000], "quickbar slot 0 -> use_skill(skill 1, selected target)") _ck(fc.casts.size() == 1, "quickbar slot 0 -> cast_skill once") + _ck(cast_targets == [[1, 2000]], "successful cast publishes resolved visual target") if fc.casts.size() == 1: _ck(int(fc.casts[0][0]) == st.motion_idx_of(1), "cast used skill 1's motion_idx") # 冷却中不再施放 qb.activate(0) _ck(fc.skill_uses.size() == 1, "on cooldown -> no second use_skill") _ck(fc.casts.size() == 1, "on cooldown -> no second cast") + qb.assign(1, "skill", 1, false) + qb.activate(1) + _ck(fc.casts.size() == 1, "same skill in another slot shares cooldown") + fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}, {"pos": 1, "type": 2, "ref": 1}] + qb.restore_from_server() + qb._process(0.0) + _ck(qb._slots[0].cd.visible and qb._slots[1].cd.visible, "server slot refresh preserves cooldown overlays") + qb.activate(0) + _ck(fc.casts.size() == 1, "server refresh cannot reset skill cooldown") + fc.quickslots = [] # restore this fixture's original server state for later UI cases + qb._clear(1) # GC_SKILL_COOLTIME_END 提前解锁 fc.skill_cooldown_end.emit(1) qb.activate(0) diff --git a/project/target_effect_test.gd b/project/target_effect_test.gd new file mode 100644 index 00000000..59cfedd5 --- /dev/null +++ b/project/target_effect_test.gd @@ -0,0 +1,61 @@ +extends SceneTree +const Anchor = preload("res://fx/target_effect_anchor.gd") +const GameScene = preload("res://game_scene.gd") +class Client extends Node: + func get_main_vid() -> int: return 1 +class World extends Node: + var target: Node3D + func node_for(_vid: int) -> Node3D: return target +class Registry extends RefCounted: + var seen: Array = [] + func spawn_target(event: Dictionary, target: Node3D, _world: Node3D): seen.append([event, target]) +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var target := Node3D.new() + root.add_child(target) + target.position = Vector3(8, 2, -5) + target.rotation.y = PI * 0.5 + var effect := Node3D.new() + root.add_child(effect) + var anchor := Anchor.new() + effect.add_child(anchor) + check(anchor.configure(target, Vector3(100, 0, 100), true), "following target configured") + check(effect.global_position.distance_to(target.to_global(Vector3(1, 1, 0))) < 0.00001, "source Z-up/cm and target rotation") + target.position.x += 3 + anchor._process(0) + check(effect.global_position.distance_to(target.to_global(Vector3(1, 1, 0))) < 0.00001, "follows target movement") + anchor.configure(target, Vector3(100, 0, 100), false) + check(effect.global_position.distance_to(target.global_position + Vector3(1, 1, 0)) < 0.00001, "snapshot offset is world-aligned") + var position := effect.global_position + target.position.x += 2 + await process_frame + check(effect.global_position == position, "snapshot does not follow") + var game := GameScene.new() + root.add_child(game) + game.client = Client.new() + game.add_child(game.client) + var world := World.new() + game.add_child(world) + world.target = target + game.net_world = world + var registry := Registry.new() + game.fx = registry + game._on_skill_cast_started(1, 2) + world.target = null # selected target later changes, original cast remains. + game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) + check(registry.seen.size() == 1 and registry.seen[0][1] == target, "event keeps cast-time target and full path") + check(registry.seen[0][0].effect == "full/path.mse", "path not reduced to basename") + target.free() + game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) + check(registry.seen.size() == 1, "despawned target not replaced by caster") + game.free() + effect.free() + await process_frame + print("target_effect_test: failures=%d" % failures) + quit(1 if failures else 0) diff --git a/project/target_effect_test.gd.uid b/project/target_effect_test.gd.uid new file mode 100644 index 00000000..4b21e895 --- /dev/null +++ b/project/target_effect_test.gd.uid @@ -0,0 +1 @@ +uid://ccjmylw80npfk diff --git a/project/testing/playable_config.gd b/project/testing/playable_config.gd new file mode 100644 index 00000000..8e6d6a7d --- /dev/null +++ b/project/testing/playable_config.gd @@ -0,0 +1,117 @@ +class_name PlayableConfig + +## 首个联网内测的配置解析器。 +## +## 这是纯数据校验层:不读取账号密码、不创建网络连接、不修改游戏状态。 +## 生产测试入口必须先通过 validate(),任何缺少的必测字段都只能得到 BLOCKED, +## 不能由测试脚本临时补默认值后继续发包。 + +const SCHEMA_VERSION := 1 +const REQUIRED_KEYS := [ + "scenario_id", "protocol", "server", "character_slot", "map_key", + "waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases", + "resolution", "loops", "timeout_seconds", +] + +static func load_file(path: String) -> Dictionary: + var result := {"ok": false, "config": {}, "errors": []} + if path.strip_edges().is_empty(): + result.errors.append("config path is empty") + return result + if not FileAccess.file_exists(path): + result.errors.append("config file does not exist: %s" % path) + return result + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + result.errors.append("config file cannot be opened") + return result + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + result.errors.append("config root must be a JSON object") + return result + var checked := validate(parsed) + result.config = parsed + result.errors = checked.errors + result.ok = bool(checked.ok) + return result + +static func validate(config: Dictionary) -> Dictionary: + var errors: Array[String] = [] + for key in REQUIRED_KEYS: + if not config.has(key): + errors.append("missing required key: %s" % key) + if int(config.get("schema_version", -1)) != SCHEMA_VERSION: + errors.append("schema_version must be %d" % SCHEMA_VERSION) + if String(config.get("scenario_id", "")).strip_edges().is_empty(): + errors.append("scenario_id must not be empty") + var protocol := String(config.get("protocol", "")).strip_edges().to_lower() + if protocol != "classic": + errors.append("protocol must be classic") + var server: Variant = config.get("server", null) + if not (server is Dictionary): + errors.append("server must be an object selected from serverinfo") + else: + for key in ["server_index", "channel_index"]: + if not server.has(key) or int(server[key]) < 0: + errors.append("server.%s must be a non-negative integer" % key) + if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0: + errors.append("character_slot must be a non-negative integer") + if String(config.get("map_key", "")).strip_edges().is_empty(): + errors.append("map_key is empty; resolve it from the server/map fixture") + var waypoints: Variant = config.get("waypoints_cm", null) + if not (waypoints is Array) or waypoints.is_empty(): + errors.append("waypoints_cm must contain at least one [x,y] centimeter point") + else: + for i in waypoints.size(): + var point: Variant = waypoints[i] + if not (point is Array) or point.size() != 2 or not _is_number(point[0]) or not _is_number(point[1]): + errors.append("waypoints_cm[%d] must be [x,y] numbers" % i) + var mobs: Variant = config.get("allowed_mob_vnums", null) + if not (mobs is Array) or mobs.is_empty(): + errors.append("allowed_mob_vnums must contain confirmed server mob vnums") + else: + _validate_positive_int_array(mobs, "allowed_mob_vnums", errors) + var drops: Variant = config.get("allowed_drop_vnums", null) + if not (drops is Array) or drops.is_empty(): + errors.append("allowed_drop_vnums must contain a deterministic pickup fixture") + else: + _validate_positive_int_array(drops, "allowed_drop_vnums", errors) + var skills: Variant = config.get("skill_cases", null) + if not (skills is Array) or skills.is_empty(): + errors.append("skill_cases must contain at least one confirmed skill case") + else: + for i in skills.size(): + if not (skills[i] is Dictionary): + errors.append("skill_cases[%d] must be an object" % i) + continue + var item: Dictionary = skills[i] + for key in ["case_id", "skill_id", "required_evidence"]: + if not item.has(key): + errors.append("skill_cases[%d] missing %s" % [i, key]) + if String(item.get("case_id", "")).strip_edges().is_empty(): + errors.append("skill_cases[%d].case_id is empty" % i) + if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255: + errors.append("skill_cases[%d].skill_id must be 1..254" % i) + if not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty(): + errors.append("skill_cases[%d].required_evidence must be non-empty" % i) + var resolution: Variant = config.get("resolution", null) + if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0: + errors.append("resolution must be [width,height]") + if int(config.get("loops", 0)) < 1: + errors.append("loops must be at least 1") + if int(config.get("timeout_seconds", 0)) < 1: + errors.append("timeout_seconds must be positive") + # This config is deliberately data-only. Reject the common accidental secret paths. + for forbidden in ["account", "username", "password", "token", "secret"]: + if config.has(forbidden): + errors.append("credentials must not be stored in config: %s" % forbidden) + return {"ok": errors.is_empty(), "errors": errors} + +static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void: + for i in values.size(): + if not _is_number(values[i]) or int(values[i]) <= 0 or float(values[i]) != float(int(values[i])): + errors.append("%s[%d] must be a positive integer" % [name, i]) + +static func _is_number(value: Variant) -> bool: + return value is int or value is float diff --git a/project/testing/playable_probe.gd b/project/testing/playable_probe.gd new file mode 100644 index 00000000..7411a648 --- /dev/null +++ b/project/testing/playable_probe.gd @@ -0,0 +1,115 @@ +class_name PlayableProbe +extends Node + +## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入, +## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。 + +signal observed(kind: String, payload: Dictionary) + +var client: Node +var flow: Node +var connection_epoch := 0 +var _connections: Array[Dictionary] = [] + +func setup(app_flow: Node, m2client: Node) -> void: + flow = app_flow + client = m2client + if client == null: + return + _bind("entered_game", Callable(self, "_on_entered_game")) + _bind("disconnected", Callable(self, "_on_disconnected")) + _bind("char_list", Callable(self, "_on_char_list")) + _bind("entity_spawned", Callable(self, "_on_entity_spawned")) + _bind("entity_despawned", Callable(self, "_on_entity_despawned")) + _bind("entity_moved", Callable(self, "_on_entity_moved")) + _bind("entity_dead", Callable(self, "_on_entity_dead")) + _bind("damage", Callable(self, "_on_damage")) + _bind("vitals_changed", Callable(self, "_on_vitals")) + _bind("ground_item_added", Callable(self, "_on_ground_item_added")) + _bind("ground_item_removed", Callable(self, "_on_ground_item_removed")) + _bind("item_picked_up", Callable(self, "_on_item_picked_up")) + _bind("inventory_changed", Callable(self, "_on_inventory_changed")) + _bind("effect_cue", Callable(self, "_on_effect_cue")) + _bind("fly_cue", Callable(self, "_on_fly_cue")) + _bind("fly_targeting", Callable(self, "_on_fly_targeting")) + +func snapshot() -> Dictionary: + var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game() + var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0 + var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0 + var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0 + var state := int(flow.state()) if flow != null and flow.has_method("state") else -1 + return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count, + "ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch} + +func disconnect_all() -> void: + for entry in _connections: + var signal_name: String = entry.signal + var callback: Callable = entry.callback + if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback): + client.disconnect(signal_name, callback) + _connections.clear() + +func _exit_tree() -> void: + disconnect_all() + +func _bind(signal_name: String, callback: Callable) -> void: + if client.has_signal(signal_name) and not client.is_connected(signal_name, callback): + client.connect(signal_name, callback) + _connections.append({"signal": signal_name, "callback": callback}) + +func _emit(kind: String, payload := {}) -> void: + var data: Dictionary = payload.duplicate(true) + data["connection_epoch"] = connection_epoch + data["received_us"] = Time.get_ticks_usec() + observed.emit(kind, data) + +func _on_entered_game() -> void: + connection_epoch += 1 + _emit("entered_game") + +func _on_disconnected(reason: String) -> void: + _emit("disconnected", {"reason": reason}) + +func _on_char_list(chars: Array) -> void: + _emit("char_list", {"count": chars.size()}) + +func _on_entity_spawned(entity: Dictionary) -> void: + _emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))}) + +func _on_entity_despawned(vid: int) -> void: + _emit("entity_despawned", {"vid": vid}) + +func _on_entity_moved(vid: int) -> void: + _emit("entity_moved", {"vid": vid}) + +func _on_entity_dead(vid: int) -> void: + _emit("entity_dead", {"vid": vid}) + +func _on_damage(vid: int, amount: int, flag: int) -> void: + _emit("damage", {"vid": vid, "amount": amount, "flag": flag}) + +func _on_vitals(vid: int) -> void: + _emit("vitals_changed", {"vid": vid}) + +func _on_ground_item_added(item: Dictionary) -> void: + _emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))}) + +func _on_ground_item_removed(vid: int) -> void: + _emit("ground_item_removed", {"vid": vid}) + +func _on_item_picked_up(vnum: int, count: int, source: String) -> void: + _emit("item_picked_up", {"vnum": vnum, "count": count, "source": source}) + +func _on_inventory_changed(window: int, cell: int) -> void: + _emit("inventory_changed", {"window": window, "cell": cell}) + +func _on_effect_cue(vid: int, name: String, special: int) -> void: + _emit("effect_cue", {"vid": vid, "name": name, "special": special}) + +func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void: + _emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid}) + +func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void: + _emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid, + "target_cm": [target_cm.x, target_cm.y], "append": append}) diff --git a/project/testing/playable_report.gd b/project/testing/playable_report.gd new file mode 100644 index 00000000..baff601d --- /dev/null +++ b/project/testing/playable_report.gd @@ -0,0 +1,125 @@ +class_name PlayableReport + +## 联网测试进程内的报告构建器。 +## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。 + +const SCHEMA_VERSION := 1 +const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"] + +var _report: Dictionary = {} +var _started_us := 0 + +func begin(run_id: String, suite: String, config: Dictionary) -> void: + _started_us = Time.get_ticks_usec() + _report = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "suite": suite, + "status": "BLOCKED", + "build": _build_info(), + "environment": _environment_info(), + "cases": [], + "events": "events.jsonl", + "failures": [], + "blocked": [], + "coverage": {"required": 0, "passed": 0}, + "exit_gate": {"checked": false, "process_code": null, "errors": []}, + } + # Keep configuration useful for reproducing a run without copying any secret. + _report["scenario_id"] = String(config.get("scenario_id", "")) + +func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void: + var normalized := status.to_upper() + if not VALID_STATUSES.has(normalized): + normalized = "FAIL" + _fail("invalid case status for %s" % case_id) + var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms, + "reason": reason, "evidence": _sanitize(evidence)} + _report.cases.append(item) + _report.coverage.required = int(_report.coverage.required) + 1 + if normalized == "PASS": + _report.coverage.passed = int(_report.coverage.passed) + 1 + elif normalized == "FAIL": + _fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"]) + elif normalized == "BLOCKED": + _report.blocked.append(reason if not reason.is_empty() else case_id) + +func add_event(event: Dictionary, events_path: String) -> void: + var safe: Variant = _sanitize(event) + var file := FileAccess.open(events_path, FileAccess.READ_WRITE) + if file == null: + file = FileAccess.open(events_path, FileAccess.WRITE) + if file == null: + _fail("cannot open events file") + return + file.seek_end() + file.store_line(JSON.stringify(safe)) + file.close() + +func finish() -> Dictionary: + if _report.is_empty(): + return {} + var required := int(_report.coverage.required) + var passed := int(_report.coverage.passed) + var failures: Array = _report.failures + if not failures.is_empty(): + _report.status = "FAIL" + elif required == 0 or passed != required or not _report.blocked.is_empty(): + _report.status = "BLOCKED" + else: + _report.status = "PASS" + _report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0 + return _report + +func write(path: String) -> bool: + var report := finish() + if report.is_empty(): + return false + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(report, " ")) + file.close() + return true + +func _fail(message: String) -> void: + _report.failures.append(message) + +func _build_info() -> Dictionary: + return { + "engine_sha256": OS.get_environment("MT_BUILD_ENGINE_SHA256"), + "extension_sha256": OS.get_environment("MT_BUILD_EXTENSION_SHA256"), + "pck_sha256": OS.get_environment("MT_BUILD_PCK_SHA256"), + "arch": OS.get_environment("MT_BUILD_ARCH"), + } + +func _environment_info() -> Dictionary: + var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO + return { + "os": OS.get_name(), + "renderer": RenderingServer.get_video_adapter_name(), + "resolution": [size.x, size.y], + } + +func _sanitize(value: Variant) -> Variant: + if value is Dictionary: + var out := {} + for key in value: + var name := String(key).to_lower() + if name in ["password", "passwd", "token", "secret", "account"]: + out[key] = "[redacted]" + else: + out[key] = _sanitize(value[key]) + return out + if value is Array: + var out_array: Array = [] + for item in value: + out_array.append(_sanitize(item)) + return out_array + if value is Vector2: + return [value.x, value.y] + if value is Vector3: + return [value.x, value.y, value.z] + if value is Object: + return "[object]" + return value diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd index 6a0bb9ce..d0daa7fa 100644 --- a/project/ui/char_select_screen.gd +++ b/project/ui/char_select_screen.gd @@ -238,7 +238,9 @@ func _build_stage() -> void: root.add_child(we) _pivot = Node3D.new() - _pivot.rotation.y = PI * 0.5 + # Animation sampling no longer reapplies the exported InitialPlacement; + # the intro pose already faces the camera without a quarter-turn correction. + _pivot.rotation.y = 0.0 root.add_child(_pivot) _cam = Camera3D.new() diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd index c92a9f8c..5ff34b76 100644 --- a/project/ui/mob_view.gd +++ b/project/ui/mob_view.gd @@ -98,6 +98,9 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b if pump.is_valid(): pump.call() model = ClassDB.instantiate("Metin2Model") model.name = "Metin2Model" + # NPC/monster GR2 uses the same source winding as PCs. Without conversion, + # back-face culling exposes interiors and hides the outward-facing surfaces. + model.set("flip_winding", true) model.set("texture_dir", _dir) model.set("gr2_path", gr2) # 重:解 gr2 + 建网格 if pump.is_valid(): pump.call() @@ -129,6 +132,12 @@ func set_anim_state(s: String) -> void: anim.set("anim_path", msa) _refresh_sound_script(msa) +# Read-only motion resolver used by the forest acceptance harness. Returning +# the selected .msa before playback lets the test distinguish a real action +# from a silent "keep the previous animation" fallback. +func get_motion_path(state: String) -> String: + return _motion_for(state) + func _process(_delta: float) -> void: if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ or not bool(anim.get("playing")): diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index c6c5cc46..bd6110c3 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -32,10 +32,9 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool: if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"): return false var cls: String = CLASS_OF[race & 3] - var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls + # pc contains races 0..3 (including female assassin/shaman); pc2 is 4..7. + var pc_dir := "pc2/ymir work/pc2/%s" % cls if race >= 4 else "PC/ymir work/pc/%s" % cls var base := _resolve_dir(assets_root, pc_dir) - if base == "": - base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模 if base == "": return false motion_dir = base.path_join("general") diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd index 6716aa6c..cb949804 100644 --- a/project/ui/quickbar.gd +++ b/project/ui/quickbar.gd @@ -25,6 +25,7 @@ const JOB_DIR := { } signal skill_activated(skill_id: int) # game_scene 接它播技能特效 +signal skill_cast_started(skill_id: int, target_vid: int) # resolved target, not later selection # §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案 signal skill_rejected(skill_id: int, code: String) @@ -44,6 +45,7 @@ var item_mouse: Node var _assets_root := "" var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}] var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] +var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session var _page := 0 var _move_from := -1 var _mobile_mode := false @@ -93,7 +95,9 @@ func restore_from_server() -> void: continue match int(qs.get("type", 0)): 1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0} - 2: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 0.0} + 2: + var skill_id := int(qs.get("ref", 0)) + restored[pos] = {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))} 3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0} _state = restored _refresh_page() @@ -108,7 +112,7 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool: var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0 if type == 0 or not client.quickslot_add(global, type, id): return false - _state[global] = {"kind": kind, "id": id, "cd_end": 0.0} + _state[global] = {"kind": kind, "id": id, "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0} _refresh_slot(slot) return true @@ -207,8 +211,13 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void: if client.has_method("use_skill"): intent_sent = client.use_skill(int(s.id), target_vid) if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)): + skill_cast_started.emit(int(s.id), target_vid) s.cd_end = _now() + _skill_cooldown(int(s.id)) _state[global] = s + _skill_cooldowns[int(s.id)] = s.cd_end + for peer in _state: + if peer.kind == "skill" and peer.id == s.id: + peer.cd_end = s.cd_end # §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过; # PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。 if target_vid != 0 and net_play and table \ @@ -550,6 +559,7 @@ func _refresh_page() -> void: _refresh_slot(slot) func _on_cd_end(skill_id: int) -> void: + _skill_cooldowns.erase(skill_id) for i in _state.size(): var s: Dictionary = _state[i] if s.kind == "skill" and s.id == skill_id: diff --git a/project/ui/ui_assets.gd b/project/ui/ui_assets.gd index a1fff624..69736fc3 100644 --- a/project/ui/ui_assets.gd +++ b/project/ui/ui_assets.gd @@ -8,7 +8,6 @@ extends RefCounted static var _cache := {} -static var _dds_helper: Object = null # DDS 运行时解码:走 Metin2World.load_dds(C++ 里的 dxt.cpp,DXT1/3/5 + BGRA8)。 # Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。 @@ -17,9 +16,12 @@ static func load_dds_image(path: String) -> Image: return null if not ClassDB.class_exists("Metin2World"): return null - if _dds_helper == null or not is_instance_valid(_dds_helper): - _dds_helper = ClassDB.instantiate("Metin2World") - var img = _dds_helper.call("load_dds", path) + # Metin2World is a Node, not RefCounted; a static reference never frees it. + # The decoder returns an independently owned Image, so release the helper + # after each decode (textures themselves remain cached by load_tex). + var helper: Object = ClassDB.instantiate("Metin2World") + var img = helper.call("load_dds", path) + helper.free() return img if img is Image else null static func load_tex(assets_root: String, vpath: String) -> Texture2D: diff --git a/project/water_reference_test.gd b/project/water_reference_test.gd new file mode 100644 index 00000000..32ecb13f --- /dev/null +++ b/project/water_reference_test.gd @@ -0,0 +1,51 @@ +extends SceneTree +var failures := 0 +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + if failures < 6: printerr("FAIL: " + label) +func _init() -> void: + call_deferred("run") +func run() -> void: + var world = ClassDB.instantiate("Metin2World") + world.auto_load = false + world.objects_enabled = false + world.env_enabled = false + world.assets_root = AssetRoot.path() + world.map_path = "OutdoorA1/metin2_map_a1" + root.add_child(world) + check(world.load_map(), "real A1 loads") + var pieces := 0 + var vertices_checked := 0 + var near_shore := 0 + var boundary_vertices := 0 + for mesh in world.find_children("*", "MeshInstance3D", true, false): + if not String(mesh.name).begins_with("Water"): + continue + pieces += 1 + var material: ShaderMaterial = mesh.mesh.surface_get_material(0) + check(material.shader.code.contains("unshaded") and material.shader.code.contains("ALBEDO = tex;") + and material.shader.code.contains("ALPHA = COLOR.a;"), "water selects texture RGB / vertex alpha") + var arrays: Array = mesh.mesh.surface_get_arrays(0) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var colors: PackedColorArray = arrays[Mesh.ARRAY_COLOR] + for i in vertices.size(): + var v := vertices[i] + # sample_height selects a neighbouring chunk on the outer border; + # no such chunk exists there. The mesh still has its padded edge data. + if v.x >= 1024.0 or v.z >= 1280.0: + boundary_vertices += 1 + continue + var depth := (v.y - float(world.sample_height(v.x, v.z))) * 100.0 + var expected := clampf(depth / 200.0, 0.0, 0.8) # A1 HeightScale=0.5 + # ArrayMesh stores vertex colors as RGBA8, like legacy diffuse DWORD. + check(absf(colors[i].a - expected) < 1.0 / 255.0 + 0.0001, "real shoreline depth formula pos=%s actual=%s expected=%s" % [v, colors[i].a, expected]) + if colors[i].a < 0.12: near_shore += 1 + vertices_checked += 1 + for i in range(0, vertices.size(), 4): + check(absf(vertices[i + 1].x - vertices[i].x - 2.0) < 0.001, "retain every water cell's depth samples") + check(pieces > 0 and vertices_checked > 0 and near_shore > 0, "water and shallow shoreline exercised") + world.free() + await process_frame + print("water_reference_test: failures=%d pieces=%d vertices=%d shallow=%d boundary_skipped=%d (geometry/material, not 40250 pixels)" % [failures, pieces, vertices_checked, near_shore, boundary_vertices]) + quit(1 if failures else 0) diff --git a/project/water_reference_test.gd.uid b/project/water_reference_test.gd.uid new file mode 100644 index 00000000..e4372a5b --- /dev/null +++ b/project/water_reference_test.gd.uid @@ -0,0 +1 @@ +uid://hpjr6orgucph diff --git a/script/audit_native_trees.mjs b/script/audit_native_trees.mjs new file mode 100644 index 00000000..217e0807 --- /dev/null +++ b/script/audit_native_trees.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +const assets = process.argv[2] ?? 'assets'; +const output = process.argv[3]; +const manifest = JSON.parse(fs.readFileSync(path.join(assets, 'TreeGeometry/manifest.json'))); +const normalize = s => s.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/'); +const converted = new Set(Object.values(manifest.trees).map(r => normalize(r.source))); +const list = pattern => execFileSync('rg', ['--files', assets, '-g', pattern], {encoding: 'utf8', maxBuffer: 8 * 1024 * 1024}).trim().split('\n').filter(Boolean); +const references = new Set(); +const propertyReferences = new Map(); +for (const file of list('*.prt')) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(/^\s*treefile\s+"([^"]+)"/gmi)) { + const reference = normalize(match[1]); + references.add(reference); + propertyReferences.set(source.split(/\r?\n/)[1].trim(), reference); + } +} +const placedReferences = new Set(); +for (const file of list('areadata.txt')) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(/^Start Object\d+\r?\n[^\n]*\n\s*(\d+)/gm)) { + const reference = propertyReferences.get(match[1]); + if (reference) placedReferences.add(reference); + } +} +const unmatched = []; +let matched = 0; +for (const file of list('*.spt')) { + const hash = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + if (manifest.trees[hash]) matched++; + else unmatched.push({file, source_sha256: hash, referenced_by_property: references.has(normalize(file))}); +} +const missing_referenced = [...references].filter(r => !converted.has(r)).sort(); +const missing_placed = [...placedReferences].filter(r => !converted.has(r)).sort(); +const report = {matched_spt_files: matched, converted_species: converted.size, referenced_species: references.size, + placed_species: placedReferences.size, missing_placed, + missing_referenced, unconverted_spt: unmatched, scope: 'SPT inventory and text PRT/AreaData references; six-map runtime validation is separate'}; +if (output) fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify({matched, converted: converted.size, referenced: references.size, placed: placedReferences.size, missing_placed, missing_referenced, unconverted: unmatched.length})); +if (!placedReferences.size || missing_placed.length) process.exitCode = 1; diff --git a/script/audit_playable_maps.mjs b/script/audit_playable_maps.mjs new file mode 100644 index 00000000..2824e775 --- /dev/null +++ b/script/audit_playable_maps.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/* Static prerequisite audit for the first playable-map fixture. It does not + * connect to a server and cannot prove which monsters a live server spawned. */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +function parseArgs(argv) { + const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); } + if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`); + const k = a.slice(2).replaceAll('-', '_'); + out[k] = argv[++i]; + } + if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean); + return out; +} + +function filesUnder(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const visit = dir => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) visit(full); else result.push(full); + } + }; + visit(root); + return result; +} + +const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/'); +const read = file => fs.readFileSync(file, 'utf8'); +const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + +function parseProperties(allFiles) { + const byId = new Map(); + for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) { + const lines = read(file).split(/\r?\n/); + const id = lines[1]?.trim(); + if (!/^\d+$/.test(id || '')) continue; + const text = lines.join('\n'); + const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || ''; + const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || ''; + byId.set(id, { id: Number(id), file, type, treefile: tree }); + } + return byId; +} + +function parseArea(file) { + const text = read(file); + const objects = []; + const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g; + for (const match of text.matchAll(blockRe)) { + const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean); + if (lines.length < 2) continue; + const id = Number(lines[1]); + if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] }); + } + return objects; +} + +function parseNpcList(file) { + const result = new Map(); + if (!fs.existsSync(file)) return result; + for (const line of read(file).split(/\r?\n/)) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]); + } + return result; +} + +function mobRecord(assets, code, race, allFiles) { + const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase()) + .filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`)); + const msm = candidates[0] || ''; + const dir = msm ? path.dirname(msm) : ''; + const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : ''; + const motlist = sibling('motlist.txt'); + const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : []; + return { + race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '', + files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) }, + action_count: actions.length, actions, + required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])), + source_sha256: msm ? sha256(msm) : null, + }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const assets = path.resolve(options.assets); + const allFiles = filesUnder(assets); + const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json'); + const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} }; + const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item])); + const properties = parseProperties(allFiles); + const maps = options.maps.map(key => { + const root = path.join(assets, key); + const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt'); + const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') }))); + const propertyIds = [...new Set(objects.map(item => item.property_id))]; + const resolved = propertyIds.map(id => { + const prop = properties.get(String(id)); + if (!prop) return { property_id: id, resolved: false }; + const record = sourceByName.get(norm(prop.treefile)); + return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'), + treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null }; + }); + const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree); + return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')), + area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length, + resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id), + tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length, + properties: resolved }; + }); + const npc = parseNpcList(path.join(assets, 'root/npclist.txt')); + const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race)); + const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles)); + const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets, + maps, tree_monster_candidates: mobs, + caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' }; + if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); } + console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length })); + if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1; +} + +main(); diff --git a/script/build_macos_release_engine.sh b/script/build_macos_release_engine.sh new file mode 100644 index 00000000..120c11a7 --- /dev/null +++ b/script/build_macos_release_engine.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Reproducible Metal-only release template used by the particle exit checks. +# Does not replace Godot's installed templates. Pass the resulting executable +# to build-macos-client.sh using MT_MAC_ENGINE and the matching MT_MAC_ARCHES. +set -euo pipefail +cd "$(dirname "$0")/.." +repo_dir="$PWD" +source_dir="$repo_dir/build/godot-particle-diagnostic" +revision="a13da4feb8d8aefc283c3763d33a2f170a18d541" +engine_arch="${1:-arm64}" +case "$engine_arch" in arm64|x86_64) ;; *) echo "Expected arm64 or x86_64" >&2; exit 1 ;; esac +if [ ! -d "$source_dir" ]; then + git clone --depth 1 --branch 4.7.1-stable https://github.com/godotengine/godot.git "$source_dir" +fi +test "$(git -C "$source_dir" rev-parse HEAD)" = "$revision" +git -C "$source_dir" diff --exit-code +git -C "$source_dir" diff --cached --exit-code +cd "$source_dir" +scons platform=macos arch="$engine_arch" target=template_release \ + debug_symbols=yes optimize=speed lto=none vulkan=no -j8 +echo "Engine: $source_dir/bin/godot.macos.template_release.$engine_arch" +shasum -a 256 "bin/godot.macos.template_release.$engine_arch" diff --git a/script/live_smoke_test.sh b/script/live_smoke_test.sh index c09a5c80..eabcecad 100755 --- a/script/live_smoke_test.sh +++ b/script/live_smoke_test.sh @@ -12,9 +12,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "$SCRIPT_DIR/.." && pwd)" -APP="$REPO/build/export/mtgodot-poc.app" +APP="${MT_SMOKE_APP:-$REPO/build/export/mtgodot-poc.app}" EXECUTABLE="$APP/Contents/MacOS/mtgodot-poc" -ARTIFACT_DIR="$REPO/build/live-smoke" +ARTIFACT_DIR="${MT_SMOKE_OUTPUT:-$REPO/build/live-smoke}" LOG="$ARTIFACT_DIR/live-smoke.log" REPORT="$ARTIFACT_DIR/live-smoke-report.json" CONFIG_FILE="${MT_SMOKE_CONFIG:-$REPO/.env.live-smoke.local}" @@ -31,6 +31,7 @@ usage() { --no-build 使用现有 APP,不重新打包 --no-mutations 只做登录、进游戏和数据读取,不发送移动/聊天/重连包 --no-reconnect 做移动和聊天,但跳过重连 + --reconnect-only 只测试登录/主动重连,不发送移动、聊天或施法 --help 显示帮助 环境变量: @@ -42,8 +43,9 @@ EOF while [ "$#" -gt 0 ]; do case "$1" in --no-build) BUILD=0 ;; - --no-mutations) MUTATIONS=0 ;; + --no-mutations) MUTATIONS=0; RECONNECT=0 ;; --no-reconnect) RECONNECT=0 ;; + --reconnect-only) MUTATIONS=0; RECONNECT=1 ;; --help) usage; exit 0 ;; *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; esac @@ -90,7 +92,10 @@ for port in 11000 13002; do fi done -pkill -x mtgodot-poc 2>/dev/null || true +if pgrep -x mtgodot-poc >/dev/null; then + echo "已有客户端运行,请先关闭后再执行独立登录测试。" >&2 + exit 2 +fi export MT_PROTOCOL=classic export MT_AUTOLOGIN=1 diff --git a/script/package_render_test.sh b/script/package_render_test.sh new file mode 100644 index 00000000..f6b7fdd3 --- /dev/null +++ b/script/package_render_test.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# A JSON PASS is insufficient: shutdown happens after the report is written. +# Keep the exported client's complete log and fail on renderer/RID leaks too. +set -euo pipefail +cd "$(dirname "$0")/.." +app_path="${MT_RENDER_APP:-$PWD/build/export/mtgodot-poc.app}" +output_dir="${MT_RENDER_OUTPUT:-$PWD/build/rendering/package-gate-$(date +%Y%m%d-%H%M%S)-$$}" +test -x "$app_path/Contents/MacOS/mtgodot-poc" +mkdir -p "$output_dir" +status=0 +env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \ + "$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$? +if [ "$status" -ne 0 ] || ! rg -q '^PKGRENDER: PASS$' "$output_dir/client.log" || \ + rg -q 'SCRIPT ERROR|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then + echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log" + exit 1 +fi +echo "PASS: package load and clean exit: $output_dir/client.log" diff --git a/script/playable_config_gate.gd b/script/playable_config_gate.gd new file mode 100644 index 00000000..63086f69 --- /dev/null +++ b/script/playable_config_gate.gd @@ -0,0 +1,14 @@ +extends SceneTree + +const Config = preload("res://testing/playable_config.gd") + +func _init() -> void: + var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG") + var result := Config.load_file(path) + if not result.ok: + for error in result.errors: + printerr("CONFIG: " + String(error)) + quit(2) + return + print("PLAYABLE CONFIG: PASS " + path) + quit(0) diff --git a/script/playable_gate_test.sh b/script/playable_gate_test.sh new file mode 100755 index 00000000..d4d2fcf8 --- /dev/null +++ b/script/playable_gate_test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# INF-02 negative/positive checks. Uses only static fixtures and a temporary +# output directory; it never starts the game or contacts the server. +set -euo pipefail +cd "$(dirname "$0")/.." +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")" +trap 'rm -R "$tmp_dir"' EXIT + +node script/validate_playable_report.mjs \ + --report test/playable/report.valid.json \ + --output "$tmp_dir/valid.json" \ + --log test/playable/clean.log --process-code 0 --require-pass + +if node script/validate_playable_report.mjs \ + --report test/playable/report.blocked.json \ + --output "$tmp_dir/blocked.json" \ + --log test/playable/clean.log --process-code 0 --require-pass; then + echo "FAIL: BLOCKED fixture was accepted" >&2 + exit 1 +fi + +if node script/validate_playable_report.mjs \ + --report test/playable/report.valid.json \ + --output "$tmp_dir/exit.json" \ + --log test/playable/clean.log --process-code 1 --require-pass; then + echo "FAIL: non-zero child exit was accepted" >&2 + exit 1 +fi + +if node script/validate_playable_report.mjs \ + --report test/playable/report.valid.json \ + --output "$tmp_dir/rid.json" \ + --log test/playable/rid-warning.log --process-code 0 --require-pass; then + echo "FAIL: renderer warning was accepted" >&2 + exit 1 +fi + +if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \ + godot --headless --path project --script playable_config_gate.gd; then + echo "FAIL: empty scenario configuration was accepted" >&2 + exit 1 +fi + +echo "playable_gate_test: PASS" diff --git a/script/playable_test.sh b/script/playable_test.sh new file mode 100755 index 00000000..5bf80b3e --- /dev/null +++ b/script/playable_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Public entry for the first playable client gate. It delegates process +# ownership and post-exit validation to run_client_gate.sh. +set -euo pipefail +cd "$(dirname "$0")/.." +app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}" +config="" +output="" +mode="playable" +timeout="180" +allow=0 +repeat=1 +suite="full" + +usage() { + cat <<'EOF' +用法: script/playable_test.sh --config CONFIG [选项] + --app APP 导出包路径 + --output DIR 单次运行输出目录(repeat=1) + --suite full 当前固定为 full/playable + --allow-gameplay 允许发送游戏状态变更请求 + --repeat N 独立进程运行次数,默认 1 + --timeout-seconds N 每次墙钟超时,默认 180 +EOF +} +while [ "$#" -gt 0 ]; do + case "$1" in + --app) app="$2"; shift 2 ;; + --config) config="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + --suite) suite="$2"; shift 2 ;; + --allow-gameplay) allow=1; shift ;; + --repeat) repeat="$2"; shift 2 ;; + --timeout-seconds) timeout="$2"; shift 2 ;; + --help) usage; exit 0 ;; + *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; + esac +done +if [ -z "$config" ] || [ ! -f "$config" ]; then echo "需要 --config CONFIG" >&2; exit 2; fi +if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi +if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi +if [ "$repeat" -gt 1 ] && [ -n "$output" ]; then echo "repeat>1 时不能指定单一 --output" >&2; exit 2; fi + +root="$output" +if [ -z "$root" ]; then root="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$}"; fi +mkdir -p "$root" +failed=0 +for index in $(seq 1 "$repeat"); do + run_dir="$root" + if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi + args=(--app "$app" --config "$config" --output "$run_dir" --mode "$mode" --timeout-seconds "$timeout") + if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi + if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi +done +if [ "$failed" -ne 0 ]; then echo "PLAYABLE SUITE: FAIL runs=$failed/$repeat" >&2; exit 1; fi +echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" diff --git a/script/rendering_batch_test.sh b/script/rendering_batch_test.sh new file mode 100644 index 00000000..9bc11a66 --- /dev/null +++ b/script/rendering_batch_test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Repeatable, non-network regression sweep; logs retain failures and warnings. +set -uo pipefail +cd "$(dirname "$0")/.." +run_dir="$(pwd)/build/rendering/batch-$(date +%Y%m%d-%H%M%S)-$$" +mkdir -p "$run_dir" +failed=0 +run_test() { + local mode="$1" test_script="$2" log="$run_dir/$1-${2%.gd}.log" + local options=(--path project) + if [ "$mode" = headless ]; then options+=(--headless); fi + "${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1 + local status=$? + if [ "$status" -ne 0 ] || rg -q 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! rg -q 'PASS|failures=0' "$log"; then + echo "FAIL $mode $test_script (exit=$status): $log" + failed=$((failed + 1)) + else + echo "PASS $mode $test_script" + fi + if rg -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi +} +for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd; do + run_test headless "$test_script" +done +for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do + run_test metal "$test_script" +done +for mode in headless metal; do + MT_TREE_OUTPUT="$run_dir/native-trees-$mode" run_test "$mode" native_tree_test.gd + run_test "$mode" effect_random_rotation_test.gd + run_test "$mode" fly_target_bounds_test.gd +done +echo "BATCH failures=$failed logs=$run_dir" +test "$failed" -eq 0 diff --git a/script/run_client_gate.sh b/script/run_client_gate.sh new file mode 100755 index 00000000..742a331a --- /dev/null +++ b/script/run_client_gate.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Run one exported client process and seal its report after exit. +# This script owns exactly one child PID; it never pkills other clients. +set -euo pipefail + +cd "$(dirname "$0")/.." +app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}" +mode="playable" +config_path="" +output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}" +timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}" +allow_gameplay=0 + +usage() { + cat <<'EOF' +用法: script/run_client_gate.sh --app APP --config CONFIG [选项] +选项: + --output DIR 本次运行目录,必须不存在或为空 + --mode MODE 默认 playable + --timeout-seconds N 墙钟超时,默认 180 + --allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求 +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --app) app_path="$2"; shift 2 ;; + --config) config_path="$2"; shift 2 ;; + --output) output_dir="$2"; shift 2 ;; + --mode) mode="$2"; shift 2 ;; + --timeout-seconds) timeout_seconds="$2"; shift 2 ;; + --allow-gameplay) allow_gameplay=1; shift ;; + --help) usage; exit 0 ;; + *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then + echo "配置不存在;不会启动客户端:$config_path" >&2 + exit 2 +fi +if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then + echo "找不到可执行包:$app_path" >&2 + exit 2 +fi +if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + echo "timeout-seconds 必须是正整数" >&2 + exit 2 +fi +if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2 + exit 2 +fi +mkdir -p "$output_dir" +log="$output_dir/client.log" +client_report="$output_dir/client-report.json" +final_report="$output_dir/report.json" +events="$output_dir/events.jsonl" + +if ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then + echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2 + exit 2 +fi +char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")" + +sha256_of() { + if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi +} +engine="$app_path/Contents/MacOS/mtgodot-poc" +pck="$app_path/Contents/Resources/mtgodot-poc.pck" +dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)" +export MT_TEST_MODE="$mode" +export MT_PLAYABLE_CONFIG="$config_path" +export MT_TEST_REPORT="$client_report" +export MT_TEST_EVENTS="$events" +export MT_TEST_OUTPUT="$output_dir" +export MT_PROTOCOL="classic" +export MT_AUTOLOGIN=1 +export MT_CHAR_SLOT="$char_slot" +export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")" +export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")" +export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")" +export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")" +file_description="$(file "$engine")" +case "$file_description" in + *arm64*) export MT_BUILD_ARCH="arm64" ;; + *x86_64*) export MT_BUILD_ARCH="x86_64" ;; + *) export MT_BUILD_ARCH="unknown" ;; +esac +if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi + +start_epoch="$(date +%s)" +set +e +env -u MT_ASSETS "$engine" >"$log" 2>&1 & +child_pid=$! +timed_out=0 +while kill -0 "$child_pid" 2>/dev/null; do + now="$(date +%s)" + if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then + timed_out=1 + kill -TERM "$child_pid" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$child_pid" 2>/dev/null || break + sleep 1 + done + kill -KILL "$child_pid" 2>/dev/null || true + break + fi + sleep 1 +done +wait "$child_pid" +app_status=$? +set -e +if [ "$timed_out" -eq 1 ]; then app_status=124; fi + +set +e +node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ + --log "$log" --process-code "$app_status" --require-pass +gate_status=$? +set -e +if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then + echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2 + exit 1 +fi +echo "PLAYABLE GATE: PASS report=$final_report log=$log" diff --git a/script/stage_tree_geometry.mjs b/script/stage_tree_geometry.mjs new file mode 100644 index 00000000..dca22af0 --- /dev/null +++ b/script/stage_tree_geometry.mjs @@ -0,0 +1,44 @@ +// Stage already-extracted, hash-matched native trees. Never manufacture geometry. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +const [source, assets] = process.argv.slice(2); +if (!source || !assets) throw Error('Usage: node script/stage_tree_geometry.mjs CONVERTED_ROOT ASSETS_ROOT'); +const hash = b => crypto.createHash('sha256').update(b).digest('hex'); +const index = JSON.parse(fs.readFileSync(path.join(source, 'asset-index.json'))); +const records = []; +for (const item of Object.values(index.assets)) { + if (item.kind !== 'tree') continue; + if (item.status !== 'converted' || item.build.native_extractor !== 'spt_extract.exe' || + item.build.coordinate_policy !== 'raw_speedtree_z_up_centimeters_to_godot_x_z_neg_y_meters') throw Error(`Unverified tree: ${item.id}`); + const raw = item.source.raw; + const spt = fs.readFileSync(path.join(assets, raw.source_physical)); + const bytes = fs.readFileSync(path.join(source, item.output)); + if (hash(spt) !== raw.source_sha256 || hash(bytes) !== item.output_sha256) throw Error(`Hash mismatch: ${item.id}`); + if (bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) throw Error('Invalid GLB'); + const gltf = JSON.parse(bytes.subarray(20, 20 + bytes.readUInt32LE(12)).toString()); + // Runtime loads mesh zero directly: reject transforms, external data and scenes + // that would need flattening instead of silently dropping their transforms. + if (gltf.nodes.length !== 1 || gltf.nodes[0].mesh !== 0 || gltf.meshes.length !== 1 || + ['matrix', 'translation', 'rotation', 'scale', 'children'].some(k => k in gltf.nodes[0]) || + gltf.buffers.some(b => b.uri) || (gltf.images ?? []).some(i => i.uri)) throw Error(`Unsupported scene: ${item.id}`); + records.push({bytes, key: raw.source_sha256, record: { + source: raw.source_physical, source_sha256: raw.source_sha256, + glb: `${raw.source_sha256}.glb`, glb_sha256: item.output_sha256, + native_extractor: item.build.native_extractor, extractor_runs: item.build.extractor_runs, + primitives: gltf.meshes[0].primitives.length, leaves: 'static_extracted_cards', wind: 'not_baked' + }}); +} +if (!records.length) throw Error('No native trees found'); +const dest = path.join(assets, 'TreeGeometry'); +// Validate the whole set before staging. Do not overwrite unrelated asset files. +fs.mkdirSync(dest, {recursive: true}); +const trees = {}; +for (const {bytes, key, record} of records) { + const target = path.join(dest, record.glb); + if (fs.existsSync(target) && hash(fs.readFileSync(target)) !== record.glb_sha256) throw Error(`Conflicting existing GLB: ${target}`); + if (!fs.existsSync(target)) fs.writeFileSync(target, bytes); + trees[key] = record; +} +fs.writeFileSync(path.join(dest, 'manifest.json'), JSON.stringify({schema_version: 1, coordinates: 'godot_y_up_meters', trees}, null, 2) + '\n'); +console.log(`Staged ${records.length} verified native tree species into ${dest}`); diff --git a/script/validate_playable_report.mjs b/script/validate_playable_report.mjs new file mode 100755 index 00000000..af40acb9 --- /dev/null +++ b/script/validate_playable_report.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/* Validate the machine-readable playable report and, when requested, seal it + * with the parent process' exit/log gate. The client cannot mark a run PASS by + * itself: the final report is written only after the child has exited. */ +import fs from 'node:fs'; +import path from 'node:path'; + +const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']); +const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m; + +function usage() { + console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]'); +} + +function argsOf(argv) { + const out = { require_pass: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--require-pass') out.require_pass = true; + else if (arg.startsWith('--')) { + const key = arg.slice(2).replaceAll('-', '_'); + if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`); + out[key] = argv[++i]; + } else throw new Error(`unknown argument: ${arg}`); + } + if (!out.report) throw new Error('--report is required'); + return out; +} + +function readJson(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (error) { return { __read_error: `${file}: ${error.message}` }; } +} + +function validate(report, options) { + const errors = []; + const readError = report?.__read_error; + if (readError) errors.push(readError); + if (!report || typeof report !== 'object' || Array.isArray(report)) { + errors.push('report root must be an object'); + return errors; + } + if (report.schema_version !== 1) errors.push('schema_version must be 1'); + if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing'); + if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run'); + if (report.suite !== 'playable') errors.push('suite must be playable'); + if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`); + for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) { + if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`); + } + if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty'); + const cases = Array.isArray(report.cases) ? report.cases : []; + let passed = 0; + for (const item of cases) { + if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') { + errors.push('case id is missing'); + continue; + } + if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`); + if (item.status === 'PASS') passed += 1; + if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`); + if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`); + for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) { + if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`); + else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`); + } + } + const coverage = report.coverage; + if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases'); + if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays'); + if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report'); + if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process'); + if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0'); + if (options.log) { + if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`); + else { + const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/); + lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); }); + } + } + if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`); + if (report.status === 'FAIL') errors.push('client report already contains failures'); + return [...new Set(errors)]; +} + +function main() { + let options; + try { options = argsOf(process.argv.slice(2)); } + catch (error) { usage(); console.error(error.message); return 2; } + const input = readJson(options.report); + const report = input.__read_error ? { + schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL', + build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [], + failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 }, + } : input; + const exitCode = options.process_code === undefined ? null : Number(options.process_code); + report.exit_gate = { checked: true, process_code: exitCode, errors: [] }; + const errors = validate(report, options); + report.exit_gate.errors = errors; + if (errors.length > 0) report.status = 'FAIL'; + const output = options.output || options.report; + fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true }); + fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); + if (errors.length) { + console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`); + errors.forEach((error) => console.error(`- ${error}`)); + return 1; + } + console.log(`PLAYABLE REPORT: PASS ${output}`); + return 0; +} + +process.exitCode = main(); diff --git a/test/playable/clean.log b/test/playable/clean.log new file mode 100644 index 00000000..92187ecb --- /dev/null +++ b/test/playable/clean.log @@ -0,0 +1 @@ +PLAYABLE FIXTURE: child completed without renderer errors diff --git a/test/playable/report.blocked.json b/test/playable/report.blocked.json new file mode 100644 index 00000000..087eaf69 --- /dev/null +++ b/test/playable/report.blocked.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "run_id": "fixture-blocked", + "suite": "playable", + "status": "BLOCKED", + "build": { + "engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "arch": "arm64" + }, + "environment": {"os": "macOS", "renderer": "Metal", "resolution": [1280, 720]}, + "cases": [{"id": "FIXTURE-01", "status": "BLOCKED", "duration_ms": 0, "reason": "fixture_not_ready", "evidence": []}], + "events": "events.jsonl", + "failures": [], + "blocked": ["fixture_not_ready"], + "coverage": {"required": 1, "passed": 0}, + "exit_gate": {"checked": false, "process_code": null, "errors": []} +} diff --git a/test/playable/report.valid.json b/test/playable/report.valid.json new file mode 100644 index 00000000..727dcb1c --- /dev/null +++ b/test/playable/report.valid.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "run_id": "fixture-valid", + "suite": "playable", + "status": "PASS", + "build": { + "engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "arch": "arm64" + }, + "environment": {"os": "macOS", "renderer": "Metal", "resolution": [1280, 720]}, + "cases": [{"id": "FIXTURE-01", "status": "PASS", "duration_ms": 1, "reason": "ok", "evidence": []}], + "events": "events.jsonl", + "failures": [], + "blocked": [], + "coverage": {"required": 1, "passed": 1}, + "exit_gate": {"checked": false, "process_code": null, "errors": []} +} diff --git a/test/playable/rid-warning.log b/test/playable/rid-warning.log new file mode 100644 index 00000000..b4af7484 --- /dev/null +++ b/test/playable/rid-warning.log @@ -0,0 +1 @@ +WARNING: 3 shaders of type CanvasItem were never freed. diff --git a/test/playable/scenario.example.json b/test/playable/scenario.example.json new file mode 100644 index 00000000..0afb401c --- /dev/null +++ b/test/playable/scenario.example.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "scenario_id": "first-mac-playable-forest", + "protocol": "classic", + "server": { + "server_index": 0, + "channel_index": 0 + }, + "character_slot": 0, + "map_key": "", + "waypoints_cm": [], + "allowed_mob_vnums": [], + "allowed_drop_vnums": [], + "skill_cases": [], + "resolution": [1280, 720], + "loops": 3, + "timeout_seconds": 120, + "notes": "Copy to a local ignored file and fill from the confirmed server fixture. Never add credentials here." +} diff --git a/test/rendering/model_baseline.json b/test/rendering/model_baseline.json index c930b5f7..2371d61a 100644 --- a/test/rendering/model_baseline.json +++ b/test/rendering/model_baseline.json @@ -4,91 +4,107 @@ "races": { "0": { "body_gr2": "warrior_novice.gr2", - "hair_bright_frac": 0.0224071972085593, + "body_sha256": "7ac881b0c60ced40c9388d6186bdf02a353a58382d12804345cb65fc3dc1fa74", + "hair_bright_frac": 0.0961239332408458, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.254901960784314, - "hair_mean_lum": 0.0607960189162477, - "hair_mean_lum_ctrl": 0.097848223242717, - "head_cover": 0.324674209407673, - "head_px": 46579, - "silhouette_px": 35073 + "hair_lum_p99": 0.270588235294118, + "hair_mean_lum": 0.0862069988797638, + "hair_mean_lum_ctrl": 0.118492882502141, + "hair_sha256": "2e3c00bda5397d579a2399cfb38a69974667e4f7b92dca6b2a709058a37d0a84", + "head_cover": 0.272221606648199, + "head_px": 45125, + "silhouette_px": 35078 }, "1": { "body_gr2": "assassin_novice.gr2", - "hair_bright_frac": 0.316447908121411, + "body_sha256": "176d88073cc5a6a4fcd4b4f62d9c401a5a63b27d50a816ad0d5fb463d86f1eab", + "hair_bright_frac": 0.0740650293050516, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.349019607843137, - "hair_mean_lum": 0.184415208398222, - "hair_mean_lum_ctrl": 0.195338585878083, - "head_cover": 0.244265393733581, - "head_px": 62428, - "silhouette_px": 36052 + "hair_lum_p99": 0.411764705882353, + "hair_mean_lum": 0.122540537286642, + "hair_mean_lum_ctrl": 0.16191718244652, + "hair_sha256": "61424a024587b0bc2dce5febc7d2af5bd1d42177611e2c2772b7fea0d21aad0f", + "head_cover": 0.163433908045977, + "head_px": 27840, + "silhouette_px": 24776 }, "2": { "body_gr2": "sura_novice.gr2", - "hair_bright_frac": 0.834898912836162, + "body_sha256": "edf32d40ea5cbc080fc3fd9798ce9a2637d2f4a7435e93b48601b05bfe490af1", + "hair_bright_frac": 0.841908258630555, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.435294117647059, - "hair_mean_lum": 0.260437632997124, - "hair_mean_lum_ctrl": 0.281076636261981, - "head_cover": 0.0281083316817606, - "head_px": 116051, + "hair_lum_p99": 0.67843137254902, + "hair_mean_lum": 0.345470992135172, + "hair_mean_lum_ctrl": 0.36430273043797, + "hair_sha256": "8de6c62889d3c55ef59d6e913f7a25e2a7cdf8748eeae5c68aded52cb1ab4eef", + "head_cover": 0.0245020445851471, + "head_px": 121296, "silhouette_px": 39682 }, "3": { "body_gr2": "shaman_novice.gr2", - "hair_bright_frac": 0.122538024274082, + "body_sha256": "1ecfd2e4e2e5e9c2280b18be0f48d749392f6de0006645668126e4ddd8935fca", + "hair_bright_frac": 0.138202264691681, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.32156862745098, - "hair_mean_lum": 0.134457749300365, - "hair_mean_lum_ctrl": 0.166435011552631, - "head_cover": 0.295606972434941, - "head_px": 60983, - "silhouette_px": 36257 + "hair_lum_p99": 0.325490196078431, + "hair_mean_lum": 0.114180786249986, + "hair_mean_lum_ctrl": 0.148597906212311, + "hair_sha256": "ef76fc009f6df5981637de614a5d68045e5ce778a1a76cc85154293f05c673fe", + "head_cover": 0.696943300601837, + "head_px": 37884, + "silhouette_px": 21923 }, "4": { "body_gr2": "warrior_novice.gr2", - "hair_bright_frac": 0.0294142102856193, + "body_sha256": "fef02eabbd4c4e4ab9b001e98af9cf04a58e04fdcc0955621fc8a2a5539ce8e9", + "hair_bright_frac": 0.232798403525548, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.23921568627451, - "hair_mean_lum": 0.0852886966119754, - "hair_mean_lum_ctrl": 0.107547303626061, - "head_cover": 0.190623521275434, - "head_px": 27473, - "silhouette_px": 27104 + "hair_lum_p99": 0.356862745098039, + "hair_mean_lum": 0.138821295782272, + "hair_mean_lum_ctrl": 0.171530843244045, + "hair_sha256": "9f17eb53e552508d9554df18fe7810e1f237f49040a47edf28aee2e3fc6218db", + "head_cover": 0.222755996904823, + "head_px": 31016, + "silhouette_px": 27285 }, "5": { "body_gr2": "assassin_novice.gr2", - "hair_bright_frac": 0.0384454367848172, + "body_sha256": "4a9b7cd7bcfdb8e94578d760bc40c05c827ec000e67d193522eaef92c3849d78", + "hair_bright_frac": 0.860336341263331, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.270588235294118, - "hair_mean_lum": 0.0734998604521351, - "hair_mean_lum_ctrl": 0.0947519741918537, - "head_cover": 0.25690174015439, - "head_px": 30572, - "silhouette_px": 24776 + "hair_lum_p99": 0.427450980392157, + "hair_mean_lum": 0.282471313531006, + "hair_mean_lum_ctrl": 0.307527867586739, + "hair_sha256": "0bf02587413c9b7b5687cc051a1afbfed6fba1ee0ebeb1093fe0b65a1ba74178", + "head_cover": 0.216423497313214, + "head_px": 60109, + "silhouette_px": 36029 }, "6": { "body_gr2": "sura_novice.gr2", - "hair_bright_frac": 0.905465795806119, + "body_sha256": "03640fdc3502a29ea313d18a206bb826a08e644e771b3b40136c7f250c4b03aa", + "hair_bright_frac": 0.956428325885184, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.427450980392157, - "hair_mean_lum": 0.277393012894398, - "hair_mean_lum_ctrl": 0.298905357949299, - "head_cover": 0.617005584237699, - "head_px": 37964, - "silhouette_px": 30280 + "hair_lum_p99": 0.698039215686274, + "hair_mean_lum": 0.406448884125668, + "hair_mean_lum_ctrl": 0.423375393471242, + "hair_sha256": "0e2e6eebaad5925540d48645a72073bad7c0556a859c581a091c5e2570227444", + "head_cover": 0.649506781750925, + "head_px": 38928, + "silhouette_px": 24781 }, "7": { "body_gr2": "shaman_novice.gr2", - "hair_bright_frac": 0.0142828216749996, + "body_sha256": "6cb2a74fc0f7b85cd58b1dbe239c5ddcaa68281d5ee37469cea8ec4b209baa40", + "hair_bright_frac": 0.356644645874942, "hair_gr2": "hair_1_1.gr2", - "hair_lum_p99": 0.219607843137255, - "hair_mean_lum": 0.0624432979584058, - "hair_mean_lum_ctrl": 0.0774457732565792, - "head_cover": 0.715665995318709, - "head_px": 36742, - "silhouette_px": 21953 + "hair_lum_p99": 0.415686274509804, + "hair_mean_lum": 0.182649001834564, + "hair_mean_lum_ctrl": 0.204288481408849, + "hair_sha256": "5c6a22c5184737111a1eafcac9b0a8f950145a923da6c59faf09bdf09ec4bb4a", + "head_cover": 0.262338937708471, + "head_px": 59061, + "silhouette_px": 36786 } } } \ No newline at end of file diff --git a/test/rendering/particle-leak/.gitignore b/test/rendering/particle-leak/.gitignore new file mode 100644 index 00000000..0f103025 --- /dev/null +++ b/test/rendering/particle-leak/.gitignore @@ -0,0 +1 @@ +.godot/ diff --git a/test/rendering/particle-leak/README.md b/test/rendering/particle-leak/README.md new file mode 100644 index 00000000..e586ccbf --- /dev/null +++ b/test/rendering/particle-leak/README.md @@ -0,0 +1,31 @@ +# Particle shader lifetime reproducer + +Independent Godot project: no game assets, autoloads, GDExtension, network, +EffectPlayer, or particles emitter. Eight short-lived ParticleProcessMaterials +cycle through three emission shapes. Weak references must all be dead before +exit; a RenderingServer synchronization also runs before quitting. + +Export this project with the installed macOS template using `--export-release` +and `--export-debug` into separate directories under the repository's `build/`. +Run each exported executable and preserve both stdout and stderr until process +exit. A run is clean only if `ISOLATED_PARTICLE_EXIT alive=0` is present and no +shader/RID error follows. The success marker alone is not a clean-exit verdict. + +2026-09-10, Godot 4.7.1 official, Apple M4/Metal: + +- Release: zero surviving materials, three ParticlesShaderRD/Shader RID leaks. +- Debug: zero surviving materials, no shader/RID leak. + +Logs: `build/rendering/particle-isolated-release.log` and +`build/rendering/particle-isolated-debug.log`. Path overrides (`--main-pack`, +external `--script`) are not supported by the installed release template; +export a standalone app instead. This project contains no copyrighted artwork +and may be used as a minimal upstream reproduction if separately authorized. + +2026-09-11: an unmodified same-revision arm64 release build using +`debug_symbols=yes optimize=speed lto=none vulkan=no` exits cleanly in this +project and in two complete game package checks. Rebuild it with +`bash script/build_macos_release_engine.sh arm64` from the repository root. +This is a verified alternative build path, not proof of a specific compiler +bug or a source fix to the installed official template. Intel and Vulkan are +outside this result. diff --git a/test/rendering/particle-leak/export_presets.cfg b/test/rendering/particle-leak/export_presets.cfg new file mode 100644 index 00000000..26eeb87b --- /dev/null +++ b/test/rendering/particle-leak/export_presets.cfg @@ -0,0 +1,9 @@ +[preset.0] +name="macOS" +platform="macOS" +runnable=true +export_filter="all_resources" +include_filter="" +exclude_filter="" +[preset.0.options] +application/bundle_identifier="org.metin2.particleprobe" diff --git a/test/rendering/particle-leak/main.gd b/test/rendering/particle-leak/main.gd new file mode 100644 index 00000000..53b057ce --- /dev/null +++ b/test/rendering/particle-leak/main.gd @@ -0,0 +1,17 @@ +extends Node +var refs: Array[WeakRef] = [] +func _ready() -> void: call_deferred("run") +func run() -> void: + for i in 8: create_material(i) + for i in 10: await get_tree().process_frame + RenderingServer.force_sync() + var alive := 0 + for reference in refs: + if reference.get_ref() != null: alive += 1 + print("ISOLATED_PARTICLE_EXIT alive=", alive) + get_tree().quit(1 if alive else 0) +func create_material(index: int) -> void: + var material := ParticleProcessMaterial.new() + material.emission_shape = index % 3 + material.get_rid() + refs.append(weakref(material)) diff --git a/test/rendering/particle-leak/main.gd.uid b/test/rendering/particle-leak/main.gd.uid new file mode 100644 index 00000000..67f463a5 --- /dev/null +++ b/test/rendering/particle-leak/main.gd.uid @@ -0,0 +1 @@ +uid://b3vjaypwq6yx6 diff --git a/test/rendering/particle-leak/main.tscn b/test/rendering/particle-leak/main.tscn new file mode 100644 index 00000000..3735e37f --- /dev/null +++ b/test/rendering/particle-leak/main.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://main.gd" id="1"] +[node name="Probe" type="Node"] +script = ExtResource("1") diff --git a/test/rendering/particle-leak/project.godot b/test/rendering/particle-leak/project.godot new file mode 100644 index 00000000..ad78286c --- /dev/null +++ b/test/rendering/particle-leak/project.godot @@ -0,0 +1,9 @@ +config_version=5 +[application] +config/name="Particle lifetime isolation" +run/main_scene="res://main.tscn" +[display] +window/size/viewport_width=320 +window/size/viewport_height=240 +[rendering] +textures/vram_compression/import_etc2_astc=true