From 3d4b63c19e0dde3e6aab138e056e2445dab0c42a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:25:10 +0200 Subject: [PATCH 0001/2756] wasmtime 48 LTS: one wasm-tools version for the runtime, packing and the SDK wasmtime 36 read WebAssembly with wasm-tools 0.236, wit-component (cargo horizon-mod, the sandbox tests) used 0.239 and the SDK's wit-bindgen 0.247: three copies of wasmparser, wasm-encoder, wit-parser and wasm-metadata. wasmtime 48, the next long-term-support line (fixes until August 2028), wit-component 0.254 and wit-bindgen 0.60 all use 0.254. No code change; horizon-mods now needs Rust 1.95. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 541 +++++++++++++----------------- crates/horizon-mod-sdk/Cargo.toml | 2 +- crates/horizon-mods/Cargo.toml | 10 +- docs/mods.md | 8 +- 4 files changed, 241 insertions(+), 320 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f832e248b..420f2226c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,9 +20,9 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "addr2line" -version = "0.25.1" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ "gimli", ] @@ -155,7 +155,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -196,6 +196,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -572,7 +583,7 @@ dependencies = [ "jni 0.21.1", "js-sys", "libc", - "mach2", + "mach2 0.4.3", "ndk", "ndk-context", "num-derive", @@ -586,6 +597,15 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -597,46 +617,48 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e92993516bcc43fd4e511b31d2e0c0c24f6b7ac18bb39e32cac2bfc05ea378e" +checksum = "e6071a4f1df4837aa03d1e4d53067784acafe270ffc7dad66a7f39832204da3f" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97eeeb61b08ec484f274119faf3a8a1b80dd300b0208c0247d16d05982fc89fe" +checksum = "5e3b92a9cc90388fe70e63640af30b734387449445aef0176ab2028e9892d70e" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782eb44158681a105d157cb0ce63e3dc83f78ef5674266c12234f49da3f5c6e8" +checksum = "f45c85646c4425cc85b868effaf20c302b437a3fb79bb27a64513dd522f07411" dependencies = [ "cranelift-entity", + "wasmtime-internal-core", ] [[package]] name = "cranelift-bitset" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd59af193204c4f025fb2c2d9ee0aeb1f60929edf000b0dfa3d45543a62f264" +checksum = "94f56b5c7d564cfa03aa269a6b98c916cab4d545196a391d31403a23ef39333a" dependencies = [ "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c29bd985057d790db25f6bc60b369b311aa2d75c2f496a7e9993585bfc7d60" +checksum = "2c073641ce6e382814a8220331cc1ea2c782a9ea5d5da6d0f7f7c0541192693e" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -648,22 +670,26 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.15.5", + "hashbrown 0.17.1", + "libm", "log", + "postcard", "pulley-interpreter", "regalloc2", "rustc-hash 2.1.3", "serde", + "serde_derive", + "sha2", "smallvec", "target-lexicon", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen-meta" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9ef8a8495f19212f92db8425f768ffc67000c5cb417a0664475a8d09596700f" +checksum = "db226fafea5194921de10dccf35ac2863500313990f770cbe8b26c51eebec4d4" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -674,37 +700,39 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3aa5099097666baf45bd68ef036327c8f29d8785fffaaa7db3b3b6b7e94bcc6" +checksum = "cf8ac46b2c8e328ca979810426ea635ef90ecc8c54fd3296bde31ab470a48a1d" [[package]] name = "cranelift-control" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26f67f4f4e92c2934f40c2ff4574400bd27326932aad937a438b7fb459f60a63" +checksum = "6754e0ab97915209ea6ce6549f6443b018e1338142ada4c856cec213ee82e4b8" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8393d09f0abaa504896069e79f4eb6f15eb1853e5645732b082dac1ed292aaa8" +checksum = "774d25e36df18696f63a284e9c4ea43455e2ebcca6308e0e02301f18c8e777e2" dependencies = [ "cranelift-bitset", "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-frontend" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "580cfaecbd11476dd82f6a393123dcbd9dfe583467df8d0c8441c641444d4985" +checksum = "69cfcb31405e2de2a48e5a6de8e07a3d8b77f35caa3e944d627780734b36c138" dependencies = [ "cranelift-codegen", + "hashbrown 0.17.1", "log", "smallvec", "target-lexicon", @@ -712,15 +740,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c044843adb7d94b1facebaad846c0a762f79a77953417e3f0182093bcbbdc38" +checksum = "2f5a26df22db546ab92be6feaf21835ae91082af0a32bbb398b277df3765412f" [[package]] name = "cranelift-native" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d4c1707214e53f2040e4d13430069ea22623287b2750ed6d62e40eba88d91a" +checksum = "19f258c08e590f90dcaf9904d25ba142ee2980c3aaeac4bb45f1452b1a407c2a" dependencies = [ "cranelift-codegen", "libc", @@ -729,9 +757,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.18" +version = "0.135.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d355a35edc49f628db7b793f3527eed31b560e77c3e8ed0e5258aad8df8a5249" +checksum = "67055b4f315e8f6af3e8eccec74ad38f7407f0fd854adbd49b4b3b27bdb2e3be" [[package]] name = "crc32fast" @@ -1003,12 +1031,6 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fdeflate" version = "0.3.7" @@ -1101,12 +1123,48 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1119,8 +1177,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1215,11 +1277,12 @@ dependencies = [ [[package]] name = "gimli" -version = "0.32.3" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ - "fallible-iterator", + "fnv", + "hashbrown 0.16.1", "indexmap", "stable_deref_trait", ] @@ -1315,7 +1378,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", - "serde", ] [[package]] @@ -1336,6 +1398,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -1486,7 +1550,7 @@ dependencies = [ name = "horizon-mod-sdk" version = "0.1.0" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.60.0", ] [[package]] @@ -1499,8 +1563,8 @@ dependencies = [ "serde_json", "toml", "wasmtime", - "wit-component 0.239.0", - "wit-parser 0.239.0", + "wit-component", + "wit-parser", "zip", ] @@ -2127,6 +2191,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "memchr" version = "2.8.3" @@ -2662,12 +2732,12 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "crc32fast", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "indexmap", "memchr", ] @@ -2972,21 +3042,21 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "pulley-interpreter" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "334ae8982dcf354e5f28e79fc6f935601e028b729cc174d3937eeeaa859057b8" +checksum = "2b8187c8e7db87d085865fc106502e7b34d5c25f8ce4c272945b21503371f6a0" dependencies = [ "cranelift-bitset", "log", "pulley-macros", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "pulley-macros" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd207bca4bc5e46cb4fcea87196dcbd78befc628c64c67d11dc99fce5cd5369" +checksum = "7f98ce0e381d9e45e63ea1d20bba3cf131797086077f43af9203f1fb7de0ed6b" dependencies = [ "proc-macro2", "quote", @@ -3094,15 +3164,16 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.12.2" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "log", "rustc-hash 2.1.3", + "serde", "smallvec", ] @@ -3155,6 +3226,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3995,7 +4072,7 @@ version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -4056,124 +4133,66 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.236.1" +version = "0.254.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" +checksum = "ebaca0b540bdb15d195ff34fb23074255948708b8916a11a339ba4513dc61906" dependencies = [ "leb128fmt", - "wasmparser 0.236.1", -] - -[[package]] -name = "wasm-encoder" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" -dependencies = [ - "leb128fmt", - "wasmparser 0.239.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.247.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" -dependencies = [ - "leb128fmt", - "wasmparser 0.247.0", + "wasmparser", ] [[package]] name = "wasm-metadata" -version = "0.239.0" +version = "0.254.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +checksum = "5d1880dd9a848aa68f387966256c34ad49abb4607e3cfa35720d2723d0591cd3" dependencies = [ "anyhow", "indexmap", - "wasm-encoder 0.239.0", - "wasmparser 0.239.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.247.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder 0.247.0", - "wasmparser 0.247.0", + "wasm-encoder", + "wasmparser", ] [[package]] name = "wasmparser" -version = "0.236.1" +version = "0.254.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757" dependencies = [ "bitflags 2.13.2", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "indexmap", "semver", "serde", ] -[[package]] -name = "wasmparser" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" -dependencies = [ - "bitflags 2.13.2", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wasmparser" -version = "0.247.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" -dependencies = [ - "bitflags 2.13.2", - "hashbrown 0.17.1", - "indexmap", - "semver", -] - [[package]] name = "wasmprinter" -version = "0.236.1" +version = "0.254.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" +checksum = "8b03c8f270f3f384f70f42869b5ff7f6253fe4f0aa8590f7edd332c1943e5f51" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.236.1", + "wasmparser", ] [[package]] name = "wasmtime" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70095b9fe63cc2a87ed206729e11544610decc184c62898c5d201ff1d666d82e" +checksum = "12300d040e8bea7e7ccde75b9919d27ebba47a217a64b09ba058ae8d3b4290b6" dependencies = [ "addr2line", - "anyhow", + "async-trait", "bitflags 2.13.2", "bumpalo", "cc", - "cfg-if", "encoding_rs", - "hashbrown 0.15.5", - "indexmap", + "futures", "libc", "log", - "mach2", + "mach2 0.6.0", "memfd", "object", "once_cell", @@ -4185,62 +4204,56 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.236.1", + "wasmparser", "wasmtime-environ", - "wasmtime-internal-asm-macros", "wasmtime-internal-component-macro", "wasmtime-internal-component-util", + "wasmtime-internal-core", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", - "wasmtime-internal-winch", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "wasmtime-environ" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a67ea8904b6e369e2218780bd1f9186f1980cb616445eb2c1f3c4e45c8397865" +checksum = "805d6554eb6dd6e62891061a31383a89d69fd891f785e5ae68f0835a554b7a2a" dependencies = [ "anyhow", + "cpp_demangle", + "cranelift-bforest", "cranelift-bitset", "cranelift-entity", "gimli", + "hashbrown 0.17.1", "indexmap", "log", "object", "postcard", + "rustc-demangle", "semver", "serde", "serde_derive", + "sha2", "smallvec", "target-lexicon", - "wasm-encoder 0.236.1", - "wasmparser 0.236.1", + "wasm-encoder", + "wasmparser", "wasmprinter", "wasmtime-internal-component-util", -] - -[[package]] -name = "wasmtime-internal-asm-macros" -version = "36.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0488a52ae93e654baecb08ce04d15d478756eb2168c2bdab7915710109161ce" -dependencies = [ - "cfg-if", + "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377e406c3f8e7bea64db3cb3ee0d406c331e8fc09d8cb2e7bf9ad74cb5d3b31a" +checksum = "b832b80853365d9d3a741681044554be291a4f977aae074b318b55c7a4835f83" dependencies = [ "anyhow", "proc-macro2", @@ -4248,23 +4261,32 @@ dependencies = [ "syn 2.0.119", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser 0.236.1", + "wit-parser", ] [[package]] name = "wasmtime-internal-component-util" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97c24503bb712afe2c797dc291b96e57556ee62c35fd951f87e96088324de37c" +checksum = "4c8a11aaf68d8945991429d45950f32490a30ddcdcdb56629816cb41a255d8fe" + +[[package]] +name = "wasmtime-internal-core" +version = "48.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea266d6cfb31c324c075c8432386654bcbb1559aa8d29be9d47eacf7dcc41012" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] [[package]] name = "wasmtime-internal-cranelift" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63ea7ee133de9109a4a1fc63dae3afc9ea32e92b8dada824fa4595dbf175f80" +checksum = "d427759553b07c296c8ed7bff8ac930a94b9ba99e6a79243ea79bcc51aa58d96" dependencies = [ - "anyhow", - "cfg-if", "cranelift-codegen", "cranelift-control", "cranelift-entity", @@ -4278,33 +4300,32 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.21", - "wasmparser 0.236.1", + "wasmparser", "wasmtime-environ", - "wasmtime-internal-math", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-fiber" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69662a0a4dbe9784d1e8add9e3c5d2db0ca348085c624c27a70992dbe2eff4a3" +checksum = "b54c8c5b02ea985149a90f917e1761af99734c6c27e6d7fdd5b6f69fc1db3950" dependencies = [ - "anyhow", "cc", - "cfg-if", "libc", "rustix 1.1.5", - "wasmtime-internal-asm-macros", + "wasmtime-environ", "wasmtime-internal-versioned-export-macros", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00cb07d23903c0be359d6b920d94774ea322759c3f4d6721b61afa03602e4804" +checksum = "72bb10c94724f072b6eca515085033e61da9efae65d054545126df37a15530c3" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -4312,83 +4333,50 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b47c37a6d026e2755fe2cf7ec5c1f4b0fba62e448ea910cdf6a07a4982c6a" +checksum = "1b781a27e8958fd07fd3135c0be216f77301efecd15cbf4f14ff210cdad5f169" dependencies = [ - "anyhow", - "cfg-if", "libc", - "windows-sys 0.60.2", + "wasmtime-internal-core", + "windows-sys 0.61.2", ] -[[package]] -name = "wasmtime-internal-math" -version = "36.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b269f0ba44102c57fd5f94f1bf4395ccd4fc2b84698fc78f627a6f2a47131a62" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "36.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e35ac9d503d503703cd24d532e53e2e93c86109b451df40a9801500045aee25" - [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83ce1d0930da44ed375701e6b4f187b46249f7efe2b1d8c1c77229a4adc82de9" +checksum = "121188de1b2741b8e35530a9b0c8adf525b3aa5913a2ca3d4522509cfbc0494a" dependencies = [ - "anyhow", - "cfg-if", "cranelift-codegen", "log", "object", + "wasmtime-environ", ] [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b6633be262400bd7abab4ff767a16e8b6a41ff117be54bf76dabecea9f96225" +checksum = "933f1990e4e5177547a4abd51dcfa5cb7616fe42b9fb6f0f2b7423627758223c" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "wasmtime-internal-winch" -version = "36.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509d1e1cf8e3b3306f291407c92bca8381d9294f8c54ad74b692538f1f5b23c7" -dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli", - "object", - "target-lexicon", - "wasmparser 0.236.1", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "winch-codegen", -] - [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.18" +version = "48.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d9a5b51b3400b4df1055724c4536ad5aa58fafe83cea5ca94f88cc9eb97563" +checksum = "417ebd61d2581d86d65413517f3a41294a9ad105da26f0dc7039912a0d97e1ed" dependencies = [ "anyhow", "bitflags 2.13.2", "heck", "indexmap", - "wit-parser 0.236.1", + "wit-component", + "wit-parser", ] [[package]] @@ -4698,26 +4686,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winch-codegen" -version = "36.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e470d7e674c41ad32dddc02d8d8a94983e0a4c516028bda60747e6dd862a567" -dependencies = [ - "anyhow", - "cranelift-assembler-x64", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon", - "thiserror 2.0.21", - "wasmparser 0.236.1", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "wasmtime-internal-math", -] - [[package]] name = "windows" version = "0.54.0" @@ -5164,42 +5132,48 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d" dependencies = [ "wit-bindgen-rust-macro", ] [[package]] name = "wit-bindgen-core" -version = "0.57.1" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f" dependencies = [ "anyhow", "heck", - "wit-parser 0.247.0", + "wit-parser", ] [[package]] name = "wit-bindgen-rust" -version = "0.57.1" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +checksum = "3df3fe9b9a0066f82fd0c2f07f79d0ffc0f85c53fa5f998516d8722712479042" dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn 2.0.119", - "wasm-metadata 0.247.0", + "wasm-metadata", "wit-bindgen-core", - "wit-component 0.247.0", + "wit-component", ] [[package]] name = "wit-bindgen-rust-macro" -version = "0.57.1" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +checksum = "d689c4bc9d6af067c651cfba444766343c9a4bdef15fad1e2efab95c0c277af0" dependencies = [ "anyhow", "prettyplease", @@ -5212,9 +5186,9 @@ dependencies = [ [[package]] name = "wit-component" -version = "0.239.0" +version = "0.254.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +checksum = "1c96b3cea6838dbea55173baa56991fcb087896c321b0a770db201e5a0bd3506" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -5223,72 +5197,17 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.239.0", - "wasm-metadata 0.239.0", - "wasmparser 0.239.0", - "wit-parser 0.239.0", -] - -[[package]] -name = "wit-component" -version = "0.247.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" -dependencies = [ - "anyhow", - "bitflags 2.13.2", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.247.0", - "wasm-metadata 0.247.0", - "wasmparser 0.247.0", - "wit-parser 0.247.0", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] name = "wit-parser" -version = "0.236.1" +version = "0.254.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.236.1", -] - -[[package]] -name = "wit-parser" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.239.0", -] - -[[package]] -name = "wit-parser" -version = "0.247.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +checksum = "22672d61bfb9df46a16b46ce38522293aee1cc4749fc84a3464ce4083b3d5fd6" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -5299,8 +5218,8 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "unicode-xid", - "wasmparser 0.247.0", + "unicode-ident", + "wasmparser", ] [[package]] diff --git a/crates/horizon-mod-sdk/Cargo.toml b/crates/horizon-mod-sdk/Cargo.toml index df223b28c3..899fb555ac 100644 --- a/crates/horizon-mod-sdk/Cargo.toml +++ b/crates/horizon-mod-sdk/Cargo.toml @@ -7,7 +7,7 @@ rust-version = "1.85" description = "Write Horizon mods in Rust: WebAssembly components for the horizon:mod world (docs/mods.md)" [dependencies] -wit-bindgen = { version = "0.57.1", default-features = false, features = ["macros", "realloc"] } +wit-bindgen = { version = "0.60.0", default-features = false, features = ["macros", "realloc"] } [lints] workspace = true diff --git a/crates/horizon-mods/Cargo.toml b/crates/horizon-mods/Cargo.toml index 8db24ac35c..d446486912 100644 --- a/crates/horizon-mods/Cargo.toml +++ b/crates/horizon-mods/Cargo.toml @@ -3,7 +3,7 @@ name = "horizon-mods" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.86" +rust-version = "1.95" description = "Horizon's mod runtime: sandboxed WebAssembly components (docs/mods.md)" [dependencies] @@ -12,11 +12,11 @@ serde_json = "1" toml = "1.1.6" zip = { version = "2", default-features = false, features = ["deflate"] } log = "0.4" -# The 36 LTS line: security fixes until August 2027, and it builds on the client's Rust. -wasmtime = { version = "36.0.18", default-features = false, features = ["runtime", "cranelift", "component-model", "std"], optional = true } +# The 48 LTS line: security fixes until August 2028, on the same wasm-tools (0.254) as wit-component and the SDK's wit-bindgen. +wasmtime = { version = "48.0.7", default-features = false, features = ["runtime", "cranelift", "component-model", "std"], optional = true } # Turns a mod's core module into a component (cargo horizon-mod build, the tests). -wit-component = { version = "0.239", optional = true } -wit-parser = { version = "0.239", optional = true } +wit-component = { version = "0.254", optional = true } +wit-parser = { version = "0.254", optional = true } [features] default = ["runtime"] diff --git a/docs/mods.md b/docs/mods.md index 4b356d3366..6aef6295ae 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -14,7 +14,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage; tick, frame, chat and command hooks | | SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 | | Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` | -| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model | +| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model | | Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run` | | In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods | | Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, shader packs, modpacks | @@ -130,8 +130,10 @@ There is no file, socket, process or raw-input interface, on purpose. | native `cdylib` plugins | none | no: per OS and arch, unstable Rust ABI | yes | none | unsafe for players | | an embedded JVM | none per mod | yes | Java | 100+ MB, seconds of startup | see the research | -wasmtime 36 is a long-term-support line (security fixes until August 2027) -that builds on the client's Rust 1.90. Only its `runtime`, `cranelift`, +wasmtime 48 is a long-term-support line (security fixes until August 2028) +and needs Rust 1.95. It reads WebAssembly with the same wasm-tools release +(0.254) as the `wit-component` that packs mods and the SDK's `wit-bindgen`, so +the build carries one copy of them. Only its `runtime`, `cranelift`, `component-model` and `std` features are on: no WASI, no async, no GC proposal, no profiling. Its cranelift backend generates native code for x86-64 and ARM64 on Windows, macOS and Linux, which is why one `.hmod` serves them From 7a1638a1908f5183102b7f649a79c4d95db8fe7c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:12:55 +0200 Subject: [PATCH 0002/2756] Renderer: remove the section face groups modelled on another renderer #503 grouped each section's solid quads by the axis side they face and skipped the groups facing away from the eye. Its code and PR named an internal class of another project as the model. Horizon doesn't build on another project's code, so the grouping goes and every section draws its solid quads in one draw, as vanilla does; the GPU's back-face culling discards the back faces, so the picture is unchanged. This costs frame time, measured on the Mac mini in the PR. A clean-room version can come back later, designed from vanilla and from first principles only. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 32 +-- crates/horizon-client/src/lod.rs | 4 - crates/horizon-client/src/lod/mesh.rs | 4 +- crates/horizon-client/src/mesher.rs | 11 - crates/horizon-client/src/mesher/facing.rs | 267 ------------------ .../src/mesher/leaf_culling_tests.rs | 2 +- 6 files changed, 15 insertions(+), 305 deletions(-) delete mode 100644 crates/horizon-client/src/mesher/facing.rs diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 59a3b5d03d..b1143be6c5 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -177,8 +177,6 @@ struct SectionGpu { start: u64, solid: u32, translucent: u32, - /// The solid quads by facing: back-facing groups are skipped. - faces: crate::mesher::FaceGroups, sort: translucency::SortState, sorted_indices: Option<(u32, u64)>, /// Its id for flat draws (`flat::Flat::add`), when they are on. @@ -2751,7 +2749,6 @@ impl Renderer { start, solid, translucent, - faces: mesh.faces, sort: translucency::SortState::new( mesh.translucent .as_chunks::<4>() @@ -3621,10 +3618,6 @@ impl Renderer { // as one list of indirect draws over the section arena. self.draws.clear(); self.draw_pages.clear(); - // The eye, camera-relative: view bobbing moves it off the camera. - let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch)) - .inverse() - .transform_point3(Vec3::ZERO); // A Java pack that draws the terrain (`gfx/java_terrain.rs`): the // world pass leaves it out, and occlusion culling tests against the // pack's depth (`Cull::test_gl`). @@ -3655,14 +3648,11 @@ impl Renderer { }; let start = draws.len(); let instances = u32::from(first_phase); - let mut indices = 0; - // Only the solid groups that can face the eye. - s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| { - let first = s.start + u64::from(first) * 4; - draws.push([quads * 6, instances, 0, first as u32, i as u32]); + let indices = s.solid * 6; + if indices > 0 { + draws.push([indices, instances, 0, s.start as u32, i as u32]); pages.push((s.page, None)); - indices += quads * 6; - }); + } let runs = if first_phase { &mut self.solid_runs } else { @@ -3699,12 +3689,16 @@ impl Renderer { for (i, v) in visible_lods.iter().enumerate() { let s = &self.lods[&v.key].mesh; let start = self.draws.len(); - s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| { - let first = s.start + u64::from(first) * 4; - self.draws - .push([quads * 6, 1, 0, first as u32, (visible.len() + i) as u32]); + if s.solid > 0 { + self.draws.push([ + s.solid * 6, + 1, + 0, + s.start as u32, + (visible.len() + i) as u32, + ]); self.draw_pages.push((s.page, None)); - }); + } self.solid_runs .push(v.distance, s.page, start, self.draws.len() - start, [0; 3]); } diff --git a/crates/horizon-client/src/lod.rs b/crates/horizon-client/src/lod.rs index f8adb1f16e..ee17acee18 100644 --- a/crates/horizon-client/src/lod.rs +++ b/crates/horizon-client/src/lod.rs @@ -1727,10 +1727,6 @@ mod tests { let expected = (key.size() * key.size() - 16 * 16) as f32; assert_eq!(top_area(&mesh.solid, 70.0), expected); assert_eq!(top_area(&mesh.translucent, 80.0 - 1.0 / 9.0), expected); - assert_eq!( - mesh.faces.quads.iter().sum::() as usize, - mesh.solid.len() / 4 - ); assert!(mesh.translucent.iter().all(|v| v.light == 240)); let side = |axis: usize, plane: f32, low: f32, high: f32| { let other = if axis == 0 { 2 } else { 0 }; diff --git a/crates/horizon-client/src/lod/mesh.rs b/crates/horizon-client/src/lod/mesh.rs index 73287af50e..852f45e5b4 100644 --- a/crates/horizon-client/src/lod/mesh.rs +++ b/crates/horizon-client/src/lod/mesh.rs @@ -2,7 +2,7 @@ use super::{Column, Span, TileKey}; use crate::{ fast_hash::{FxHashMap, FxHashSet}, - mesher::{FaceGroups, OPAQUE_LAYER, SectionMesh, TerrainVertex}, + mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex}, streaming::WorkerContext, }; use horizon_assets::model::Direction; @@ -558,7 +558,6 @@ fn mesh_columns_with_edges( emit(&mut mesh, key, rect) } } - mesh.faces = FaceGroups::group(&mut mesh.solid); mesh } @@ -1003,7 +1002,6 @@ mod tests { } let mesh = mesh(&columns); assert_eq!(mesh.solid.len(), 6 * 4); - assert_eq!(mesh.faces.quads.iter().sum::(), 6); assert!(mesh.translucent.is_empty()); for q in mesh.solid.as_chunks::<4>().0.iter() { let center = q diff --git a/crates/horizon-client/src/mesher.rs b/crates/horizon-client/src/mesher.rs index a9e9f6823f..ec6f2ae739 100644 --- a/crates/horizon-client/src/mesher.rs +++ b/crates/horizon-client/src/mesher.rs @@ -3,7 +3,6 @@ //! `ModelBlockRenderer`/`BlockModelLighter` (smooth light, ambient occlusion, //! cardinal shading, tint sources, block offsets; see `lighting`). Fluids //! follow `FluidRenderer` (see `fluid`). -mod facing; mod fluid; mod lighting; use crate::{ @@ -13,7 +12,6 @@ use crate::{ chunk::{Chunk, MIN_Y}, models::catalog::BakedFace, }; -pub use facing::{FaceGroups, GROUPS as FACE_GROUPS}; use horizon_core::{ biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed, }; @@ -116,8 +114,6 @@ mod vertex_tests { } pub struct SectionMesh { pub solid: Vec, - /// `solid`'s quads by the side they face (`facing`). - pub faces: FaceGroups, pub translucent: Vec, /// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction /// order) are connected through non-opaque blocks. @@ -131,7 +127,6 @@ impl Default for SectionMesh { fn default() -> Self { Self { solid: Vec::new(), - faces: FaceGroups::default(), translucent: Vec::new(), visibility: ALL_VISIBLE, block_entities: Vec::new(), @@ -698,12 +693,6 @@ pub struct Mesher<'a> { impl Mesher<'_> { /// `region[dz][dx]` are the chunks around the meshed column (centre 1,1). pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh { - let mut out = self.tesselate(region, sy); - out.faces = facing::group(&mut out.solid); - out - } - /// The section's quads in model order, before `facing::group`. - pub(crate) fn tesselate(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh { let mut out = SectionMesh::default(); let center = region[1][1].expect("centre chunk"); let section = ¢er.sections[sy as usize]; diff --git a/crates/horizon-client/src/mesher/facing.rs b/crates/horizon-client/src/mesher/facing.rs deleted file mode 100644 index 7bafe9e82b..0000000000 --- a/crates/horizon-client/src/mesher/facing.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Solid quads grouped by the side they face, as Sodium's `ModelQuadFacing` -//! does. A quad in the plane `x = p` facing +X is front-facing only while the -//! eye is at `x > p`, and terrain culls back faces, so when the eye is on the -//! back side of every quad of a group the renderer skips the group's draw: -//! the GPU would have discarded all of it after shading its vertices. The -//! picture is unchanged; the vertex work for those quads is gone. -use super::TerrainVertex; -use glam::Vec3; - -/// Groups in buffer order: the negative sides, quads not in an axis plane -/// (rotated model elements, sloped fluid tops), then the positive sides. -/// With the eye outside a section on every axis, one side per axis is -/// visible, so the drawn groups form one to three runs. -pub const GROUPS: usize = 7; -const UNALIGNED: usize = 3; -/// The group of a quad facing `-axis` (0 x, 1 y, 2 z) is `NEGATIVE[axis]`, of -/// one facing `+axis` `GROUPS - 1 - NEGATIVE[axis]`. -const NEGATIVE: [usize; 3] = [0, 2, 1]; -/// How far (blocks) the eye must be behind every plane of a group before -/// the group is skipped: rounding on the GPU never makes such a quad -/// front-facing. -const MARGIN: f32 = 1.0 / 16.0; - -/// The solid quads' groups, filled by `group`. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct FaceGroups { - /// Quads per group, in buffer order. - pub quads: [u32; GROUPS], - /// Per axis group, the plane nearest the side the group faces: the - /// lowest plane of a positive group and the highest of a negative one. - planes: [f32; GROUPS], -} - -impl Default for FaceGroups { - /// Everything unaligned: always drawn. - fn default() -> Self { - Self { - quads: [0; GROUPS], - planes: [0.0; GROUPS], - } - } -} - -impl FaceGroups { - /// Group arbitrary-sized terrain quads, including distant LOD tiles. - pub fn group(solid: &mut Vec) -> Self { - group(solid) - } - - /// Whether group `g` can hold a front-facing quad for an eye at `eye`, - /// relative to the section's minimum corner. - pub fn visible(&self, g: usize, eye: Vec3) -> bool { - if g == UNALIGNED { - return true; - } - let (axis, positive) = axis(g); - if positive { - eye[axis] > self.planes[g] - MARGIN - } else { - eye[axis] < self.planes[g] + MARGIN - } - } - /// The quad ranges (first quad, quad count) to draw for an eye at `eye` - /// relative to the section's minimum corner, adjacent groups merged. - pub fn ranges(&self, eye: Vec3, mut out: impl FnMut(u32, u32)) { - let mut first = 0; - let mut run: Option<(u32, u32)> = None; - for g in 0..GROUPS { - let quads = self.quads[g]; - if quads > 0 { - if self.visible(g, eye) { - run = Some(match run { - Some((start, len)) => (start, len + quads), - None => (first, quads), - }); - } else if let Some((start, len)) = run.take() { - out(start, len); - } - } - first += quads; - } - if let Some((start, len)) = run { - out(start, len); - } - } -} - -/// The axis and sign of each axis group (`UNALIGNED`'s entry is unused). -const AXES: [(usize, bool); GROUPS] = { - let mut axes = [(0, false); GROUPS]; - let mut axis = 0; - while axis < 3 { - axes[NEGATIVE[axis]] = (axis, false); - axes[GROUPS - 1 - NEGATIVE[axis]] = (axis, true); - axis += 1; - } - axes -}; - -/// The axis and sign of axis group `g`. -fn axis(g: usize) -> (usize, bool) { - debug_assert_ne!(g, UNALIGNED); - AXES[g] -} - -/// The group of one quad, and its plane: an axis group only when all four -/// corners lie exactly in one axis plane. -fn facing(quad: &[TerrainVertex]) -> (usize, f32) { - let p = |i: usize| Vec3::from(quad[i].pos); - let normal = (p(1) - p(0)).cross(p(2) - p(0)); - for axis in 0..3 { - let [a, b] = [(axis + 1) % 3, (axis + 2) % 3]; - let plane = quad[0].pos[axis]; - if normal[axis] != 0.0 - && normal[a] == 0.0 - && normal[b] == 0.0 - && quad.iter().all(|v| v.pos[axis] == plane) - { - let negative = NEGATIVE[axis]; - let g = if normal[axis] > 0.0 { - GROUPS - 1 - negative - } else { - negative - }; - return (g, plane); - } - } - (UNALIGNED, 0.0) -} - -/// Reorder `solid` by group, keeping the order of quads within a group (so -/// coplanar quads such as a grass block's overlay still draw after their -/// base), and return the groups. -pub fn group(solid: &mut Vec) -> FaceGroups { - let mut groups = FaceGroups::default(); - for (g, plane) in groups.planes.iter_mut().enumerate() { - if g != UNALIGNED { - *plane = if axis(g).1 { f32::MAX } else { f32::MIN }; - } - } - let faces: Vec = solid - .as_chunks::<4>() - .0 - .iter() - .map(|quad| { - let (g, plane) = facing(quad); - groups.quads[g] += 1; - if g != UNALIGNED { - let nearest = &mut groups.planes[g]; - *nearest = if axis(g).1 { - nearest.min(plane) - } else { - nearest.max(plane) - }; - } - g as u8 - }) - .collect(); - if faces.windows(2).all(|w| w[0] <= w[1]) { - return groups; - } - let mut next = [0usize; GROUPS]; - let mut start = 0; - for g in 0..GROUPS { - next[g] = start; - start += groups.quads[g] as usize; - } - let mut sorted = vec![solid[0]; solid.len()]; - for (quad, &g) in solid.as_chunks::<4>().0.iter().zip(&faces) { - let at = next[g as usize] * 4; - sorted[at..at + 4].copy_from_slice(quad); - next[g as usize] += 1; - } - *solid = sorted; - groups -} - -#[cfg(test)] -mod tests { - use super::*; - - fn vertex(pos: [f32; 3], layer: u16) -> TerrainVertex { - TerrainVertex::new(pos, [0.0; 2], layer, 0, 0) - } - /// A unit quad in the plane `axis = plane`, wound to face `sign`, tagged - /// with `layer` so tests can follow it. - fn quad(axis: usize, plane: f32, sign: f32, layer: u16) -> Vec { - let corner = |u: f32, v: f32| { - let mut p = [0.0; 3]; - p[axis] = plane; - p[(axis + 1) % 3] = u; - p[(axis + 2) % 3] = v; - vertex(p, layer) - }; - let mut q = vec![ - corner(0.0, 0.0), - corner(1.0, 0.0), - corner(1.0, 1.0), - corner(0.0, 1.0), - ]; - if sign < 0.0 { - q.reverse(); - } - q - } - - #[test] - fn quads_group_by_the_side_their_winding_faces() { - for a in 0..3 { - for sign in [-1.0, 1.0] { - let q = quad(a, 4.0, sign, 0); - let (g, plane) = facing(&q); - assert_eq!(axis(g), (a, sign > 0.0), "axis {a} sign {sign}"); - assert_eq!(plane, 4.0); - let normal = (Vec3::from(q[1].pos) - Vec3::from(q[0].pos)) - .cross(Vec3::from(q[2].pos) - Vec3::from(q[0].pos)); - assert_eq!(normal[a].signum(), sign); - } - } - // A cross plant's diagonal quad faces no axis. - let diagonal = [ - [0.0, 0.0, 0.0], - [1.0, 0.0, 1.0], - [1.0, 1.0, 1.0], - [0.0, 1.0, 0.0], - ] - .map(|p| vertex(p, 0)); - assert_eq!(facing(&diagonal).0, UNALIGNED); - } - - #[test] - fn grouping_is_stable_and_skips_only_back_facing_groups() { - // +X at x = 3 and x = 7 (layers 1, 2), -X at x = 5 (3), an unaligned - // quad (4), a coplanar overlay on the x = 3 quad (5). - let diagonal = [ - [0.0, 0.0, 0.0], - [1.0, 0.0, 1.0], - [1.0, 1.0, 1.0], - [0.0, 1.0, 0.0], - ] - .map(|p| vertex(p, 4)); - let mut solid = [ - quad(0, 3.0, 1.0, 1), - quad(0, 5.0, -1.0, 3), - diagonal.to_vec(), - quad(0, 7.0, 1.0, 2), - quad(0, 3.0, 1.0, 5), - ] - .concat(); - let groups = group(&mut solid); - let layers: Vec = solid.chunks(4).map(|q| q[0].layer).collect(); - assert_eq!(layers, [3, 4, 1, 2, 5]); - let ranges = |eye: Vec3| { - let mut out = Vec::new(); - groups.ranges(eye, |first, count| out.push((first, count))); - out - }; - // Between the planes: every group can face the eye. - assert_eq!(ranges(Vec3::new(4.0, 8.0, 8.0)), [(0, 5)]); - // Beyond x = 7: the -X quad at x = 5 faces away. - assert_eq!(ranges(Vec3::new(20.0, 8.0, 8.0)), [(1, 4)]); - // Before x = 3: every +X quad faces away. - assert_eq!(ranges(Vec3::new(-10.0, 8.0, 8.0)), [(0, 2)]); - // Within the margin of the nearest plane: still drawn. - assert_eq!(ranges(Vec3::new(3.0, 8.0, 8.0)), [(0, 5)]); - } -} diff --git a/crates/horizon-client/src/mesher/leaf_culling_tests.rs b/crates/horizon-client/src/mesher/leaf_culling_tests.rs index 679b19090a..1273c806d4 100644 --- a/crates/horizon-client/src/mesher/leaf_culling_tests.rs +++ b/crates/horizon-client/src/mesher/leaf_culling_tests.rs @@ -42,7 +42,7 @@ fn leaf_culling_removes_only_internal_leaf_and_root_faces() { cutout_leaves, cull_leaves, } - .tesselate(®ion, (0 - MIN_Y) / 16) + .mesh(®ion, (0 - MIN_Y) / 16) .solid }; From 9223b401e7bb8864711e1f05c891b9d3187bbadb Mon Sep 17 00:00:00 2001 From: some-du6e <103129948+some-du6e@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:20:45 -0600 Subject: [PATCH 0003/2756] Decode every item data component a server sends Horizon decoded 25 of 26.3's 122 data component types. Item data has no length prefix, so a stack with any other component (an armour trim, banner patterns, pot sherds, a stew's effects, a plugin's item) made the whole packet undecodable and it was dropped: on a Paper server the inventory arrived empty until each slot was clicked, the advancements screen said "There doesn't seem to be anything here..." and the recipe book was empty. Every other component is now read to its end with its vanilla StreamCodec (item/skip.rs) and kept as its bytes, written back as they came; components without a network codec go as their NBT. The client leaves them out of the item's components, so the item keeps its prototype's values for them. tools/test_item_wire_parity.py checks it against vanilla: the oracle encodes 374 stacks with ItemStack.OPTIONAL_STREAM_CODEC (every default component value of every item, SNBT fixtures for the rest, direct holders and removals built in Java), covering all 122 component types, and the Rust probe must consume every byte, find the same component ids and encode the read-past ones back to the same bytes. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 2 + crates/horizon-protocol/Cargo.toml | 4 + .../examples/item_wire_probe.rs | 64 +++ crates/horizon-protocol/src/item.rs | 45 +- crates/horizon-protocol/src/item/skip.rs | 427 ++++++++++++++++++ .../parity/VanillaItemWireOracle.java | 155 +++++++ tools/test_item_wire_parity.py | 177 ++++++++ 7 files changed, 871 insertions(+), 3 deletions(-) create mode 100644 crates/horizon-protocol/examples/item_wire_probe.rs create mode 100644 crates/horizon-protocol/src/item/skip.rs create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java create mode 100755 tools/test_item_wire_parity.py diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index da0def0fb1..0e61271781 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -596,6 +596,8 @@ impl Game<'_> { continue; }; let value = match c { + // Read past, not decoded: the item keeps its prototype's value. + Component::Raw(_) => continue, Component::Int(v) if *kind == component::RARITY => { let rarity = ["common", "uncommon", "rare", "epic"]; Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into()) diff --git a/crates/horizon-protocol/Cargo.toml b/crates/horizon-protocol/Cargo.toml index 71b93a1fe1..6467ed8494 100644 --- a/crates/horizon-protocol/Cargo.toml +++ b/crates/horizon-protocol/Cargo.toml @@ -22,5 +22,9 @@ sha2 = "0.10" name = "protocol_probe" path = "examples/probe.rs" +[[example]] +name = "item_wire_probe" +path = "examples/item_wire_probe.rs" + [lints] workspace = true diff --git a/crates/horizon-protocol/examples/item_wire_probe.rs b/crates/horizon-protocol/examples/item_wire_probe.rs new file mode 100644 index 0000000000..19760ca1a3 --- /dev/null +++ b/crates/horizon-protocol/examples/item_wire_probe.rs @@ -0,0 +1,64 @@ +//! The item wire harness's Rust side (tools/test_item_wire_parity.py): reads +//! the oracle's stacks (JSON lines with `label` and `hex`), decodes each with +//! `item::Slot` and prints, per stack, whether every byte was consumed, the +//! patch's component ids (added, then removed), which of them Horizon +//! decoded into its own types (the rest it reads past, keeping their bytes) +//! and whether encoding the decoded stack gives the same bytes back. +//! +//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl +use horizon_protocol::{ + buf::{Codec, Reader, Writer}, + item::{Component, Slot}, +}; +use serde_json::{Value, json}; +use std::io::Write; + +fn hex(s: &str) -> Option> { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()) + .collect() +} + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let input = std::fs::read_to_string(args.get(1).ok_or("usage: item_wire_probe IN OUT")?)?; + let mut out = std::io::BufWriter::new(std::fs::File::create( + args.get(2).ok_or("usage: item_wire_probe IN OUT")?, + )?); + for line in input.lines().filter(|l| !l.trim().is_empty()) { + let v: Value = serde_json::from_str(line)?; + let label = v["label"].clone(); + let Some(bytes) = v["hex"].as_str().and_then(hex) else { + continue; + }; + let mut r = Reader::new(&bytes); + let result = match Slot::read(&mut r) { + Err(e) => json!({"label": label, "error": e.to_string()}), + Ok(stack) => { + let mut w = Writer::new(); + Slot::write(&stack, &mut w); + let (added, removed): (Vec, Vec) = + stack.as_ref().map_or_else(Default::default, |s| { + (s.added.iter().map(|(k, _)| *k).collect(), s.removed.clone()) + }); + let typed: Vec = stack + .iter() + .flat_map(|s| &s.added) + .filter(|(_, c)| !matches!(c, Component::Raw(_))) + .map(|(k, _)| *k) + .collect(); + json!({ + "label": label, + "left": r.remaining(), + "added": added, + "removed": removed, + "typed": typed, + "same_bytes": w.data == bytes, + }) + } + }; + writeln!(out, "{result}")?; + } + Ok(()) +} diff --git a/crates/horizon-protocol/src/item.rs b/crates/horizon-protocol/src/item.rs index 650fa4baa1..0aa940a386 100644 --- a/crates/horizon-protocol/src/item.rs +++ b/crates/horizon-protocol/src/item.rs @@ -2,11 +2,13 @@ //! item's registry id and a data component patch, plus the hashed form the //! client sends back in container clicks (HashedStack). use crate::{ - Error, Result, + Result, buf::{Codec, Reader, Writer}, }; use horizon_world::nbt::Tag; +mod skip; + /// minecraft:data_component_type registry ids (26.3). pub mod component { pub const CUSTOM_DATA: i32 = 0; @@ -78,6 +80,9 @@ pub enum Component { WrittenBook(WrittenBook), /// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates. Stacks(Vec), + /// A component Horizon reads past without interpreting it: its encoded + /// bytes, written back as they came. + Raw(Vec), } /// `Filterable.streamCodec`: the raw value, then an optional filtered one @@ -224,11 +229,16 @@ impl Component { hidden: (0..n).map(|_| r.varint()).collect::>()?, } } - other => return Err(Error::new(format!("unsupported item component {other}"))), + other => { + let start = r.position(); + skip::component(other, r)?; + Self::Raw(r.since(start).to_vec()) + } }) } fn write(&self, kind: i32, w: &mut Writer) { match self { + Self::Raw(bytes) => w.bytes(bytes), Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v), Self::Int(v) => w.varint(*v), Self::Unit => {} @@ -582,7 +592,8 @@ mod tests { #[test] fn unknown_components_are_errors() { - let bytes = [1, 5, 1, 0, 99]; + // 122 is past DataComponents' last id (cushion_color, 121). + let bytes = [1, 5, 1, 0, 122]; assert!(Slot::read(&mut Reader::new(&bytes)).is_err()); } @@ -631,4 +642,32 @@ mod tests { [("textures".to_owned(), "x".to_owned(), None)] ); } + + #[test] + fn components_read_past_keep_their_bytes() { + // Vanilla 26.3's encoding (tools/test_item_wire_parity.py) of a chestplate's + // armour trim, a banner's patterns (one direct), a pot's sherds and a stew's + // effects: one server's inventory, recipe book and advancements had them. + for (hex, kind) in [ + ("01bd0801003a0502", 58), + ("01890b01004a02200e000b6d696e6563726166743a7801790b", 74), + ("01880301004c01f60801000001be0c0100000001d30c010000", 76), + ("01a408010037020f6412a001", 55), + ] { + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect(); + let mut r = Reader::new(&bytes); + let stack = Slot::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0, "component {kind}"); + assert!(matches!( + stack.as_ref().and_then(|s| s.component(kind)), + Some(Component::Raw(_)) + )); + let mut w = Writer::new(); + Slot::write(&stack, &mut w); + assert_eq!(w.data, bytes, "component {kind}"); + } + } } diff --git a/crates/horizon-protocol/src/item/skip.rs b/crates/horizon-protocol/src/item/skip.rs new file mode 100644 index 0000000000..152d0f0f18 --- /dev/null +++ b/crates/horizon-protocol/src/item/skip.rs @@ -0,0 +1,427 @@ +//! Reading past the data components Horizon doesn't interpret: each +//! function consumes exactly what its vanilla StreamCodec writes +//! (DataComponents' `networkSynchronized` codecs; a component without one +//! goes as its persistent Codec's NBT, `ByteBufCodecs.fromCodecWithRegistries`). +//! Item data has no length prefix, so a component read short or long makes +//! everything after it unreadable. +use super::{Component, Template}; +use crate::{ + Error, Result, + buf::{Codec, Reader}, +}; + +type Read = fn(&mut Reader) -> Result<()>; + +/// DataComponents' registry order (26.3) for the ids `Component::read` +/// doesn't decode itself. +pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> { + match kind { + // Unit.STREAM_CODEC: glider, waxed. + 34 | 120 => Ok(()), + // Components without a network codec go as their NBT: intangible_projectile, + // map_decorations, debug_stick_state, recipes, lock, container_loot. + 22 | 49 | 59 | 68 | 81 | 82 => nbt(r), + // A VarInt (VAR_INT, an idMapper, a registry or holderRegistry id): damage_type, + // enchantable, additional_trade_cost, block_transformer, villager_food, dye, + // ominous_bottle_amplifier, base_color, the mob variants and colours, the pottery + // pattern and cushion_color. + 8 | 31 | 42 | 43 | 44 | 46 | 65 | 75 | 88..=108 | 110..=117 | 121 => varint(r), + // FLOAT: minimum_attack_charge, potion_duration_scale. + 7 | 54 => f32(r), + // Identifier.STREAM_CODEC: tooltip_style, note_block_sound. + 35 | 73 => ident(r), + // UseEffects: canSprint, interactVibrations, speedMultiplier. + 5 => { + r.bool()?; + r.bool()?; + f32(r) + } + // AdventureModePredicate: can_place_on, can_break. + 14 | 15 => list(r, block_predicate), + 16 => list(r, attribute_modifier), + // FoodProperties.DIRECT_STREAM_CODEC: nutrition, saturation, canAlwaysEat. + 23 => { + varint(r)?; + f32(r)?; + boolean(r) + } + // Consumable: consumeSeconds, animation, sound, hasConsumeParticles, onConsumeEffects. + 24 => { + f32(r)?; + varint(r)?; + sound(r)?; + boolean(r)?; + list(r, consume_effect) + } + // UseRemainder, SulfurCubeContent: one template. + 25 | 80 => template(r), + // UseCooldown: seconds, cooldownGroup. + 26 => { + f32(r)?; + optional(r, ident) + } + // DamageResistant, Repairable, provides_banner_patterns: a holder set. + 27 | 33 | 67 => holder_set(r), + 28 => tool(r), + // Weapon: itemDamagePerAttack, disableBlockingForSeconds. + 29 => { + varint(r)?; + f32(r) + } + 32 => equippable(r), + // DeathProtection: deathEffects. + 36 => list(r, consume_effect), + 37 => blocks_attacks(r), + // PiercingWeapon: dealsKnockback, dismounts, sound, hitSound. + 38 => { + boolean(r)?; + boolean(r)?; + optional(r, sound)?; + optional(r, sound) + } + 39 => kinetic_weapon(r), + // SwingAnimation: type, duration. + 40 | 41 => { + varint(r)?; + varint(r) + } + // BundleContents: templates. + 52 => list(r, template), + 53 => potion_contents(r), + // SuspiciousStewEffects: effect, duration. + 55 => list(r, |r| { + varint(r)?; + varint(r) + }), + // ArmorTrim: material, then Holder (assetId, description, decal). + 58 => { + trim_material(r)?; + holder(r, |r| { + ident(r)?; + nbt(r)?; + boolean(r) + }) + } + // TypedEntityData (entity_data, block_entity_data): the type id, then a compound. + 60 | 62 => { + varint(r)?; + nbt(r) + } + // CustomData: bucket_entity_data. + 61 => nbt(r), + // InstrumentComponent: Holder (soundEvent, useDuration, range, + // durabilityDamage, description). + 63 => holder(r, |r| { + sound(r)?; + f32(r)?; + f32(r)?; + varint(r)?; + nbt(r) + }), + 64 => trim_material(r), + // JukeboxPlayable: Holder (soundEvent, description, lengthInSeconds, + // comparatorOutput). + 66 => holder(r, |r| { + sound(r)?; + nbt(r)?; + f32(r)?; + varint(r) + }), + // LodestoneTracker: target (GlobalPos: dimension, BlockPos), tracked. + 69 => { + optional(r, |r| { + ident(r)?; + r.i64().map(drop) + })?; + boolean(r) + } + 70 => firework_explosion(r), + // Fireworks: flightDuration, explosions. + 71 => { + varint(r)?; + list(r, firework_explosion) + } + // BannerPatternLayers: Holder (assetId, translationKey), colour. + 74 => list(r, |r| { + holder(r, |r| { + ident(r)?; + string(r) + })?; + varint(r) + }), + // PotDecorations: back, left, right, front. + 76 => (0..4).try_for_each(|_| optional(r, template)), + // ItemContainerContents: optional templates. + 77 => list(r, |r| optional(r, template)), + // BlockItemStateProperties: a string to string map. + 78 => list(r, |r| { + string(r)?; + string(r) + }), + // Bees: Occupant (entity type and data, ticksInHive, minTicksInHive). + 79 => list(r, |r| { + varint(r)?; + nbt(r)?; + varint(r)?; + varint(r) + }), + 83 => sound(r), + // Compostable: layers. + 84 => resolvable(r, i32), + // CookingFuel (burnTime, speedMultiplier), BrewingFuel (uses, speedMultiplier). + 85 | 86 => { + resolvable(r, i32)?; + resolvable(r, f32) + } + // MobVisibility: targetingEntityTypes, visibility. + 87 => { + holder_set(r)?; + f32(r) + } + // Holder: width, height, assetId, title, author. + 109 => holder(r, |r| { + varint(r)?; + varint(r)?; + ident(r)?; + optional(r, nbt)?; + optional(r, nbt) + }), + // SignText: four messages, four optional filtered ones, colour, hasGlowingText. + 118 | 119 => { + (0..4).try_for_each(|_| nbt(r))?; + optional(r, |r| (0..4).try_for_each(|_| nbt(r)))?; + varint(r)?; + boolean(r) + } + other => Err(Error::new(format!("unsupported item component {other}"))), + } +} + +fn boolean(r: &mut Reader) -> Result<()> { + r.bool().map(drop) +} +fn varint(r: &mut Reader) -> Result<()> { + r.varint().map(drop) +} +fn f32(r: &mut Reader) -> Result<()> { + r.f32().map(drop) +} +fn i32(r: &mut Reader) -> Result<()> { + r.i32().map(drop) +} +/// ByteBufCodecs.STRING_UTF8. +fn string(r: &mut Reader) -> Result<()> { + r.string(32767).map(drop) +} +/// Identifier.STREAM_CODEC, ResourceKey.streamCodec. +fn ident(r: &mut Reader) -> Result<()> { + string(r) +} +/// A text component or compound (fromCodecWithRegistries, COMPOUND_TAG). +fn nbt(r: &mut Reader) -> Result<()> { + r.nbt().map(drop) +} +fn template(r: &mut Reader) -> Result<()> { + Template::read(r).map(drop) +} +/// ByteBufCodecs.optional: a flag, then the value. +fn optional(r: &mut Reader, value: Read) -> Result<()> { + if r.bool()? { value(r) } else { Ok(()) } +} +/// ByteBufCodecs.list and map: a VarInt count, then the elements (a map's +/// key and value each). Every element is at least a byte, so the count +/// can't pass what is left. +fn list(r: &mut Reader, element: Read) -> Result<()> { + let n = r.count(r.remaining())?; + (0..n).try_for_each(|_| element(r)) +} +/// ByteBufCodecs.holder: 0 and the value itself, or the registry id plus one. +fn holder(r: &mut Reader, direct: Read) -> Result<()> { + if r.varint()? == 0 { direct(r) } else { Ok(()) } +} +/// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids. +fn holder_set(r: &mut Reader) -> Result<()> { + match r.varint()? { + 0 => ident(r), + n => { + let n = usize::try_from(n - 1).map_err(|_| Error::new("negative holder set size"))?; + if n > r.remaining() { + return Err(Error::new("holder set past the end")); + } + (0..n).try_for_each(|_| varint(r)) + } + } +} +/// SoundEvent.STREAM_CODEC: a holder of a location and an optional fixed range. +fn sound(r: &mut Reader) -> Result<()> { + holder(r, |r| { + ident(r)?; + optional(r, f32) + }) +} +/// ResolvableInt, ResolvableFloat: Either a constant (true) or a context provider's key. +fn resolvable(r: &mut Reader, constant: Read) -> Result<()> { + if r.bool()? { constant(r) } else { ident(r) } +} +/// TrimMaterial.STREAM_CODEC: a holder of a palette id and a description. +fn trim_material(r: &mut Reader) -> Result<()> { + holder(r, |r| { + ident(r)?; + nbt(r) + }) +} +/// MobEffectInstance: the effect id, then its Details. +fn mob_effect(r: &mut Reader) -> Result<()> { + varint(r)?; + effect_details(r) +} +/// MobEffectInstance.Details: amplifier, duration, ambient, showParticles, showIcon, then the +/// hidden effect's Details, recursively. +fn effect_details(r: &mut Reader) -> Result<()> { + varint(r)?; + varint(r)?; + boolean(r)?; + boolean(r)?; + boolean(r)?; + optional(r, effect_details) +} +/// ConsumeEffect.STREAM_CODEC: the consume_effect_type id, then that type's fields. +fn consume_effect(r: &mut Reader) -> Result<()> { + match r.varint()? { + // apply_effects: effects, probability. + 0 => { + list(r, mob_effect)?; + f32(r) + } + // remove_effects: a mob effect holder set. + 1 => holder_set(r), + // clear_all_effects. + 2 => Ok(()), + // teleport_randomly: diameter, directionalParticles. + 3 => { + f32(r)?; + boolean(r) + } + // play_sound. + 4 => sound(r), + other => Err(Error::new(format!("unknown consume effect type {other}"))), + } +} +/// BlockPredicate: blocks, properties, nbt, then DataComponentMatchers (exact, partial). +fn block_predicate(r: &mut Reader) -> Result<()> { + optional(r, holder_set)?; + // StatePropertiesPredicate: PropertyMatchers, each a name and an + // Either. + optional(r, |r| { + list(r, |r| { + string(r)?; + if r.bool()? { + string(r) + } else { + optional(r, string)?; + optional(r, string) + } + }) + })?; + optional(r, nbt)?; + // DataComponentExactPredicate: TypedDataComponents, a component type id and its value. + list(r, |r| { + let kind = r.varint()?; + Component::read(kind, r).map(drop) + })?; + // DataComponentPredicate.STREAM_CODEC: an Either of a predicate type id or a component + // type id, then the predicate as NBT (fromCodecWithRegistries). + list(r, |r| { + boolean(r)?; + varint(r)?; + nbt(r) + }) +} +/// ItemAttributeModifiers.Entry: attribute, modifier (id, amount, operation), slot, display. +fn attribute_modifier(r: &mut Reader) -> Result<()> { + varint(r)?; + ident(r)?; + r.f64()?; + varint(r)?; + varint(r)?; + // Display.Type: default, hidden, or override and its text. + match r.varint()? { + 0 | 1 => Ok(()), + 2 => nbt(r), + other => Err(Error::new(format!( + "unknown attribute modifier display {other}" + ))), + } +} +/// Tool: rules (blocks, speed, correctForDrops), defaultMiningSpeed, damagePerBlock, +/// canDestroyBlocksInCreative. +fn tool(r: &mut Reader) -> Result<()> { + list(r, |r| { + holder_set(r)?; + optional(r, f32)?; + optional(r, boolean) + })?; + f32(r)?; + varint(r)?; + boolean(r) +} +/// Equippable: slot, equipSound, assetId, cameraOverlay, allowedEntities, dispensable, +/// swappable, damageOnHurt, equipOnInteract, canBeSheared, shearingSound. +fn equippable(r: &mut Reader) -> Result<()> { + varint(r)?; + sound(r)?; + optional(r, ident)?; + optional(r, ident)?; + optional(r, holder_set)?; + (0..5).try_for_each(|_| boolean(r))?; + sound(r) +} +/// BlocksAttacks: blockDelaySeconds, disableCooldownScale, damageReductions (angle, type, +/// base, factor), itemDamage (threshold, base, factor), bypassedBy, blockSound, disableSound. +fn blocks_attacks(r: &mut Reader) -> Result<()> { + f32(r)?; + f32(r)?; + list(r, |r| { + f32(r)?; + optional(r, holder_set)?; + f32(r)?; + f32(r) + })?; + (0..3).try_for_each(|_| f32(r))?; + optional(r, holder_set)?; + optional(r, sound)?; + optional(r, sound) +} +/// KineticWeapon: contactCooldownTicks, delayTicks, the dismount, knockback and damage +/// conditions (maxDurationTicks, minSpeed, minRelativeSpeed), forwardMovement, +/// damageMultiplier, sound, hitSound. +fn kinetic_weapon(r: &mut Reader) -> Result<()> { + varint(r)?; + varint(r)?; + (0..3).try_for_each(|_| { + optional(r, |r| { + varint(r)?; + f32(r)?; + f32(r) + }) + })?; + f32(r)?; + f32(r)?; + optional(r, sound)?; + optional(r, sound) +} +/// PotionContents: potion, customColor, customEffects, customName. +fn potion_contents(r: &mut Reader) -> Result<()> { + optional(r, varint)?; + optional(r, i32)?; + list(r, mob_effect)?; + optional(r, string) +} +/// FireworkExplosion: shape, colors, fadeColors, hasTrail, hasTwinkle. +fn firework_explosion(r: &mut Reader) -> Result<()> { + varint(r)?; + list(r, i32)?; + list(r, i32)?; + boolean(r)?; + boolean(r) +} diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java new file mode 100644 index 0000000000..cc95bb3110 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java @@ -0,0 +1,155 @@ +package dev.horizonlab.parity; + +import io.netty.buffer.Unpooled; +import it.unimi.dsi.fastutil.objects.Reference2ObjectMap; +import it.unimi.dsi.fastutil.objects.Reference2ObjectMaps; +import java.io.PrintWriter; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Optional; +import net.minecraft.core.Holder; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.component.TypedDataComponent; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.world.entity.decoration.painting.PaintingVariant; +import net.minecraft.world.item.JukeboxPlayable; +import net.minecraft.world.item.JukeboxSong; +import net.minecraft.world.item.component.MapPostProcessing; +import net.minecraft.util.Unit; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** + * Item stacks as a server sends them (ItemStack.OPTIONAL_STREAM_CODEC), for + * every data component: each default component value of every vanilla item + * set on a stick (so it is in the patch), the SNBT fixtures given in a file + * (one `labelstack SNBT` per line), and a few patches built directly. + * Prints one JSON line per stack: its label, the encoded bytes in hex and the + * patch's component type ids in encoding order (added, then removed), or + * the error. + */ +public final class VanillaItemWireOracle { + private static final int PER_TYPE = 12; + + public static void main(String[] args) throws Exception { + ParityData.init(); + List fixtures = new ArrayList<>(); + for (String line : Files.readAllLines(Path.of(args[0]))) { + if (line.isBlank() || line.startsWith("#")) continue; + fixtures.add(line.split("\t", 2)); + } + try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) { + // Each distinct default value, once per component type and + // encoding, and at most PER_TYPE of them for one type. + Set seen = new HashSet<>(); + Map, Integer> perType = new HashMap<>(); + for (var item : BuiltInRegistries.ITEM) { + for (TypedDataComponent c : item.components()) { + ItemStack stack = new ItemStack(Items.STICK); + if (!set(stack, c)) continue; + String hex = encode(stack); + if (hex == null || perType.getOrDefault(c.type(), 0) >= PER_TYPE) continue; + if (!seen.add(BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()) + hex)) continue; + perType.merge(c.type(), 1, Integer::sum); + print(out, "default " + BuiltInRegistries.ITEM.getKey(item) + " " + BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()), stack); + } + } + for (String[] f : fixtures) { + try { + print(out, f[0], ParityData.stack(f[1])); + } catch (Exception e) { + out.println("{\"label\":" + json(f[0]) + ",\"error\":" + json(String.valueOf(e)) + "}"); + } + } + // Components without a persistent codec, direct holders item SNBT + // can't name, and removals. + ItemStack direct = new ItemStack(Items.STICK); + direct.set(DataComponents.ADDITIONAL_TRADE_COST, 4); + direct.set(DataComponents.MAP_POST_PROCESSING, MapPostProcessing.SCALE); + direct.set(DataComponents.JUKEBOX_PLAYABLE, new JukeboxPlayable(Holder.direct( + new JukeboxSong(SoundEvents.ARMOR_EQUIP_CHAIN, Component.literal("S"), 10f, 3)))); + direct.set(DataComponents.PAINTING_VARIANT, Holder.direct(new PaintingVariant( + 2, 1, Identifier.withDefaultNamespace("kebab"), Optional.of(Component.literal("T")), Optional.empty()))); + print(out, "non-persistent and direct", direct); + ItemStack locked = new ItemStack(Items.STONE); + locked.set(DataComponents.CREATIVE_SLOT_LOCK, Unit.INSTANCE); + print(out, "creative_slot_lock", locked); + ItemStack removed = new ItemStack(Items.APPLE, 3); + removed.remove(DataComponents.FOOD); + removed.remove(DataComponents.CONSUMABLE); + removed.set(DataComponents.DAMAGE, 4); + print(out, "removed food and consumable", removed); + print(out, "empty", ItemStack.EMPTY); + } + } + + @SuppressWarnings("unchecked") + private static boolean set(ItemStack stack, TypedDataComponent c) { + if (stack.getPrototype().has(c.type()) && java.util.Objects.equals(stack.getPrototype().get(c.type()), c.value())) return false; + stack.set((DataComponentType) c.type(), c.value()); + return true; + } + + private static String encode(ItemStack stack) { + var buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), ParityData.access); + try { + ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, stack); + } catch (RuntimeException e) { + return null; + } + byte[] bytes = new byte[buf.readableBytes()]; + buf.readBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private static void print(PrintWriter out, String label, ItemStack stack) { + String hex = encode(stack); + if (hex == null) { + out.println("{\"label\":" + json(label) + ",\"error\":\"encode failed\"}"); + return; + } + // DataComponentPatch's encoding order: its map's fast iteration, added + // components, then removals. + StringBuilder kinds = new StringBuilder(); + StringBuilder removed = new StringBuilder(); + for (var e : Reference2ObjectMaps.fastIterable(patchMap(stack.getComponentsPatch()))) { + // Removed (package-private) marks a removal. + StringBuilder to = e.getValue().getClass().getName().equals("net.minecraft.core.component.Removed") ? removed : kinds; + to.append(to.isEmpty() ? "" : ",").append(id(e.getKey())); + } + out.println("{\"label\":" + json(label) + ",\"hex\":\"" + hex + "\",\"added\":[" + kinds + "],\"removed\":[" + removed + "]}"); + } + + @SuppressWarnings("unchecked") + private static Reference2ObjectMap, Object> patchMap(DataComponentPatch patch) { + try { + Field map = DataComponentPatch.class.getDeclaredField("map"); + map.setAccessible(true); + return (Reference2ObjectMap, Object>) map.get(patch); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static int id(DataComponentType type) { + return BuiltInRegistries.DATA_COMPONENT_TYPE.getId(type); + } + + private static String json(String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } +} diff --git a/tools/test_item_wire_parity.py b/tools/test_item_wire_parity.py new file mode 100755 index 0000000000..1be6042ec5 --- /dev/null +++ b/tools/test_item_wire_parity.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Item stacks on the wire: every data component, as vanilla 26.3 encodes it. + +VanillaItemWireOracle encodes stacks with ItemStack.OPTIONAL_STREAM_CODEC +(what a server sends in inventories, recipe books and advancements): each +default component value of every vanilla item, the SNBT fixtures below +(direct holders, tags and lists, optional fields, every consume effect, +block predicates with component matchers, the mob variants) and patches +built in Java (components without a persistent codec, removals). The Rust +probe decodes each with horizon_protocol::item::Slot and must consume every +byte and find the same component ids in the same order; a stack whose +components Horizon only reads past must also encode back to the same bytes. +Every id in DataComponents must be covered.""" +import argparse +import json +import os +import platform +import subprocess +from pathlib import Path +try: + from . import lab, test_rust_parity as base +except ImportError: + import lab + import test_rust_parity as base + +COMPONENT_TYPES = 122 + +FIXTURES = [ + ('custom_data', '{custom_data:{a:1,b:"x",c:[1,2]}}'), + ('unbreakable', '{unbreakable:{}}'), + ('custom_name', '{custom_name:{text:"Hi",color:"red",bold:1b}}'), + ('lore', '{lore:["a",{text:"b",italic:0b}]}'), + ('enchantments', '{enchantments:{"minecraft:sharpness":5,"minecraft:unbreaking":3}}'), + ('can_break tag', '{can_break:{blocks:"#minecraft:logs"}}'), + ('can_break list state nbt', '{can_break:[{blocks:["minecraft:stone","minecraft:dirt"],state:{snowy:"true",age:{min:"1",max:"3"},level:{min:"2"}},nbt:"{a:1b}"}]}'), + ('can_place_on components', '{can_place_on:{blocks:"minecraft:chest",components:{"minecraft:custom_name":"x","minecraft:damage":3},predicates:{"minecraft:custom_data":{a:1},"minecraft:damage":{durability:{min:1}}}}}'), + ('can_place_on empty', '{can_place_on:[{}]}'), + ('custom_model_data', '{custom_model_data:{floats:[1.0f,2.5f],flags:[1b,0b],strings:["a"],colors:[16711680]}}'), + ('tooltip_display', '{tooltip_display:{hide_tooltip:1b,hidden_components:["minecraft:enchantments","minecraft:damage"]}}'), + ('repair_cost', '{repair_cost:7}'), + ('intangible_projectile', '{intangible_projectile:{}}'), + ('tooltip_style', '{tooltip_style:"minecraft:fancy"}'), + ('interact_animation', '{interact_animation:{type:"stab",duration:9}}'), + ('dyed_color', '{dyed_color:16711935}'), + ('map_id', '{map_id:42}'), + ('written_book_content', '{written_book_content:{title:{raw:"T",filtered:"F"},author:"me",generation:1,pages:[{raw:"p1",filtered:"q1"},"p2"],resolved:1b}}'), + ('trim registry', '{trim:{material:"minecraft:gold",pattern:"minecraft:coast"}}'), + ('trim direct', '{trim:{material:{palette_id:"minecraft:x",description:"X"},pattern:{asset_id:"minecraft:y",description:"Y",decal:1b}}}'), + ('block_entity_data', '{block_entity_data:{id:"minecraft:chest",Items:[]}}'), + ('lodestone_tracker target', '{lodestone_tracker:{target:{dimension:"minecraft:the_nether",pos:[1,-64,3]},tracked:0b}}'), + ('lodestone_tracker empty', '{lodestone_tracker:{}}'), + ('firework_explosion', '{firework_explosion:{shape:"star",colors:[1,2],fade_colors:[3],has_trail:1b,has_twinkle:0b}}'), + ('profile name', '{profile:{name:"Notch"}}'), + ('profile full', '{profile:{name:"jeb_",id:[I;1,2,3,4],properties:[{name:"textures",value:"abc",signature:"sig"}]}}'), + ('note_block_sound', '{note_block_sound:"minecraft:block.note_block.bell"}'), + ('base_color', '{base_color:"lime"}'), + ('sulfur_cube_content', '{sulfur_cube_content:{id:"minecraft:stone",count:1}}'), + ('lock', '{lock:{items:"minecraft:diamond"}}'), + ('container_loot', '{container_loot:{loot_table:"minecraft:chests/simple_dungeon",seed:5L}}'), + ('waxed', '{waxed:{}}'), + ('banner_patterns', '{banner_patterns:[{pattern:"minecraft:stripe_bottom",color:"red"},{pattern:{asset_id:"minecraft:x",translation_key:"y"},color:"blue"}]}'), + ('pot_decorations', '{pot_decorations:{back:{id:"minecraft:brick"},left:{id:"minecraft:angler_pottery_sherd"},front:{id:"minecraft:skull_pottery_sherd",count:1}}}'), + ('suspicious_stew_effects', '{suspicious_stew_effects:[{id:"minecraft:night_vision",duration:100},{id:"minecraft:poison"}]}'), + ('potion_contents full', '{potion_contents:{potion:"minecraft:swiftness",custom_color:123,custom_effects:[{id:"minecraft:speed",amplifier:1b,duration:200,ambient:1b,show_particles:0b,show_icon:1b,hidden_effect:{id:"minecraft:speed",duration:100,hidden_effect:{id:"minecraft:speed",duration:50}}}],custom_name:"x"}}'), + ('potion_contents bare', '{potion_contents:{}}'), + ('container', '{container:[{slot:0,item:{id:"minecraft:stone",count:3}},{slot:5,item:{id:"minecraft:diamond_sword",components:{"minecraft:damage":5,"minecraft:trim":{material:"minecraft:gold",pattern:"minecraft:coast"}}}}]}'), + ('bundle_contents', '{bundle_contents:[{id:"minecraft:stone",count:2},{id:"minecraft:potion",components:{"minecraft:potion_contents":{potion:"minecraft:water"}}}]}'), + ('charged_projectiles', '{charged_projectiles:[{id:"minecraft:arrow"},{id:"minecraft:firework_rocket",components:{"minecraft:fireworks":{flight_duration:2b,explosions:[{shape:"burst",colors:[5]}]}}}]}'), + ('block_state', '{block_state:{facing:"north",half:"top"}}'), + ('bees', '{bees:[{entity_data:{id:"minecraft:bee",Health:5f},ticks_in_hive:5,min_ticks_in_hive:10}]}'), + ('entity_data', '{entity_data:{id:"minecraft:pig",NoAI:1b}}'), + ('bucket_entity_data', '{bucket_entity_data:{Health:5f}}'), + ('instrument direct', '{instrument:{sound_event:{sound_id:"minecraft:x",range:8f},use_duration:3f,range:64f,description:"Horn"}}'), + ('painting variant', '{"minecraft:painting/variant":"minecraft:kebab"}'), + ('sign_text_front', '{sign_text_front:{messages:["a","b","c","d"],filtered_messages:["e","f","g","h"],color:"red",has_glowing_text:1b}}'), + ('sign_text_back', '{sign_text_back:{messages:["a","","",""]}}'), + ('map_decorations', '{map_decorations:{a:{type:"minecraft:player",x:1.0d,z:2.0d,rotation:0f}}}'), + ('debug_stick_state', '{debug_stick_state:{"minecraft:oak_stairs":"facing"}}'), + ('recipes', '{recipes:["minecraft:stone_bricks"]}'), + ('attribute_modifiers', '{attribute_modifiers:[{type:"minecraft:attack_damage",id:"minecraft:x",amount:1.5d,operation:"add_value",slot:"mainhand",display:{type:"override",value:"hi"}},{type:"minecraft:armor",id:"minecraft:y",amount:2d,operation:"add_multiplied_total",display:{type:"hidden"}}]}'), + ('consumable all effects', '{consumable:{consume_seconds:2f,animation:"drink",sound:{sound_id:"minecraft:x",range:4f},has_consume_particles:0b,on_consume_effects:[{type:"apply_effects",effects:[{id:"minecraft:speed",duration:20}],probability:0.5f},{type:"remove_effects",effects:"minecraft:poison"},{type:"remove_effects",effects:["minecraft:poison","minecraft:wither"]},{type:"clear_all_effects"},{type:"teleport_randomly",diameter:8f},{type:"play_sound",sound:"minecraft:entity.generic.eat"}]}}'), + ('death_protection', '{death_protection:{death_effects:[{type:"clear_all_effects"}]}}'), + ('equippable full', '{equippable:{slot:"head",equip_sound:"minecraft:item.armor.equip_gold",asset_id:"minecraft:gold",camera_overlay:"minecraft:misc/pumpkinblur",allowed_entities:"#minecraft:skeletons",dispensable:0b,swappable:0b,damage_on_hurt:0b,equip_on_interact:1b,can_be_sheared:1b,shearing_sound:{sound_id:"minecraft:y"}}}'), + ('equippable bare', '{equippable:{slot:"chest"}}'), + ('tool rules', '{tool:{rules:[{blocks:"#minecraft:mineable/pickaxe",speed:4f,correct_for_drops:1b},{blocks:["minecraft:stone"]}],default_mining_speed:2f,damage_per_block:2,can_destroy_blocks_in_creative:0b}}'), + ('blocks_attacks full', '{blocks_attacks:{block_delay_seconds:0.5f,disable_cooldown_scale:2f,damage_reductions:[{horizontal_blocking_angle:45f,type:"#minecraft:is_fire",base:1f,factor:0.5f},{base:0f,factor:1f}],item_damage:{threshold:3f,base:1f,factor:2f},bypassed_by:"#minecraft:bypasses_shield",block_sound:"minecraft:item.shield.block",disable_sound:{sound_id:"minecraft:z"}}}'), + ('damage_resistant', '{damage_resistant:{types:"#minecraft:is_fire"}}'), + ('repairable list', '{repairable:{items:["minecraft:diamond","minecraft:emerald"]}}'), + ('use_cooldown group', '{use_cooldown:{seconds:1.5f,cooldown_group:"minecraft:x"}}'), + ('use_remainder', '{use_remainder:{id:"minecraft:bowl",count:2}}'), + ('fireworks', '{fireworks:{flight_duration:3b,explosions:[{shape:"creeper",colors:[1],fade_colors:[2,3],has_trail:1b},{shape:"small_ball"}]}}'), + ('provides_banner_patterns', '{provides_banner_patterns:"#minecraft:pattern_item/globe"}'), + ('wolf variant', '{"minecraft:wolf/variant":"minecraft:ashen","minecraft:wolf/sound_variant":"minecraft:angry","minecraft:wolf/collar":"red"}'), + ('fox salmon parrot', '{"minecraft:fox/variant":"snow","minecraft:salmon/size":"large","minecraft:parrot/variant":"blue"}'), + ('tropical fish', '{"minecraft:tropical_fish/pattern":"flopper","minecraft:tropical_fish/base_color":"white","minecraft:tropical_fish/pattern_color":"black"}'), + ('mooshroom rabbit', '{"minecraft:mooshroom/variant":"brown","minecraft:rabbit/variant":"gold"}'), + ('pig cow chicken', '{"minecraft:pig/variant":"minecraft:cold","minecraft:pig/sound_variant":"minecraft:big","minecraft:cow/variant":"minecraft:warm","minecraft:cow/sound_variant":"minecraft:classic","minecraft:chicken/variant":"minecraft:cold","minecraft:chicken/sound_variant":"minecraft:classic"}'), + ('nautilus frog horse', '{"minecraft:zombie_nautilus/variant":"minecraft:warm","minecraft:frog/variant":"minecraft:cold","minecraft:horse/variant":"chestnut"}'), + ('llama axolotl cat', '{"minecraft:llama/variant":"brown","minecraft:axolotl/variant":"blue","minecraft:cat/variant":"minecraft:tabby","minecraft:cat/sound_variant":"minecraft:classic","minecraft:cat/collar":"blue"}'), + ('sheep shulker villager', '{"minecraft:sheep/color":"pink","minecraft:shulker/color":"purple","minecraft:villager/variant":"minecraft:taiga"}'), + ('trim material direct provider', '{provides_trim_material:{palette_id:"minecraft:x",description:"X"}}'), + ('everything at once', '{damage:3,unbreakable:{},custom_name:"n",enchantments:{"minecraft:mending":1},trim:{material:"minecraft:gold",pattern:"minecraft:coast"},banner_patterns:[{pattern:"minecraft:globe",color:"red"}],pot_decorations:{right:{id:"minecraft:brick"}},lore:["x"],rarity:"epic"}'), +] +# Fixtures on another item than a stick (the components need it, or read better there). +ITEMS = {'profile name': 'player_head', 'profile full': 'player_head', 'trim registry': 'diamond_chestplate', + 'trim direct': 'diamond_chestplate', 'banner_patterns': 'white_banner', 'pot_decorations': 'decorated_pot', + 'everything at once': 'diamond_chestplate'} + + +def compare(vanilla, rust): + """Failures, stacks checked, and the component ids covered.""" + results = {r['label']: r for r in rust} + failures, covered = [], set() + for v in vanilla: + if 'error' in v: + failures.append(f"oracle {v['label']}: {v['error']}") + continue + covered.update(v['added'] + v['removed']) + r = results.get(v['label']) + if r is None or 'error' in r: + failures.append(f"{v['label']}: {r and r['error']}") + elif r['left'] or r['added'] != v['added'] or r['removed'] != v['removed']: + failures.append(f"{v['label']}: {r['left']} bytes left, ids {r['added']}/{r['removed']}, vanilla {v['added']}/{v['removed']}") + elif not r['same_bytes'] and not r['typed']: + failures.append(f"{v['label']}: read past, but encoded back differently") + missing = sorted(set(range(COMPONENT_TYPES)) - covered) + if missing: + failures.append(f'component ids no stack covers: {missing}') + return failures, len(vanilla), covered + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prepare', action='store_true') + args = parser.parse_args() + build = lab.LOCAL / 'item-wire-parity' + build.mkdir(parents=True, exist_ok=True) + report_path = build / 'report.json' + report_path.unlink(missing_ok=True) + if args.prepare: + lab.prepare() + lock, jars, libraries = base.verify_artifacts() + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java' + classes = build / 'classes' + classes.mkdir(exist_ok=True) + + def run(command, name): + with (build / (name + '.log')).open('w', encoding='utf-8') as log: + base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT) + dependency_path = os.pathsep.join(map(str, libraries)) + run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', + lab.ROOT / 'mods/horizon/src/test/java', '-cp', str(jars['server']) + os.pathsep + dependency_path, + '-d', classes, oracle], 'compile-java') + fixtures = build / 'fixtures.tsv' + fixtures.write_text(''.join(f'{label}\t{{id:"minecraft:{ITEMS.get(label, "stick")}",count:1,components:{snbt}}}\n' + for label, snbt in FIXTURES), encoding='utf-8') + vanilla_path, rust_path = build / 'vanilla.jsonl', build / 'rust.jsonl' + run(['java', '-Xmx1G', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]), + 'dev.horizonlab.parity.VanillaItemWireOracle', fixtures, vanilla_path], 'oracle') + run(['cargo', 'build', '--release', '-p', 'horizon-protocol', '--example', 'item_wire_probe'], 'compile-rust') + binary = lab.cargo_target() / 'release/examples' / ('item_wire_probe.exe' if os.name == 'nt' else 'item_wire_probe') + run([binary, vanilla_path, rust_path], 'rust') + read = lambda path: [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()] + failures, stacks, covered = compare(read(vanilla_path), read(rust_path)) + for failure in failures[:40]: + print(failure) + if failures: + raise SystemExit(f'{len(failures)} item wire mismatches (of {stacks} stacks)') + lab.write_json(report_path, { + 'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), + 'stacks': stacks, 'component_types_covered': len(covered), + 'oracle_sha256': lab.digest(vanilla_path, 'sha256'), 'rust_sha256': lab.digest(rust_path, 'sha256')}) + print(f'Item wire parity passed: {stacks} stacks, all {len(covered)} component types: {report_path}') + + +if __name__ == '__main__': + main() From eb0a6f74e6d5911cc63e637b6dcd14076c28ac0e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:42:05 +0200 Subject: [PATCH 0004/2756] Shader packs, clean room 2: macros and #if conditions The integer arithmetic of #if/#elif that a pack's .properties files and, later, its GLSL preprocessor share: defined X and defined(X), decimal, hexadecimal and octal literals, the C operators with C precedence, macros expanded to their values (a macro is not expanded inside its own expansion), and an identifier that is not a macro read as 0. A bad condition says why. The standard macros that need no renderer: MC_VERSION, from the pinned version by the documented rule (26.3 is 260300), and the system's MC_OS_*. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/horizon-shaders/Cargo.toml | 1 + crates/horizon-shaders/src/condition.rs | 265 ++++++++++++++++++++++++ crates/horizon-shaders/src/lib.rs | 5 + crates/horizon-shaders/src/macros.rs | 131 ++++++++++++ 5 files changed, 403 insertions(+) create mode 100644 crates/horizon-shaders/src/condition.rs create mode 100644 crates/horizon-shaders/src/macros.rs diff --git a/Cargo.lock b/Cargo.lock index 6c7279aa70..7036ea5cac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,7 @@ dependencies = [ name = "horizon-shaders" version = "0.1.0" dependencies = [ + "horizon-core", "zip", ] diff --git a/crates/horizon-shaders/Cargo.toml b/crates/horizon-shaders/Cargo.toml index ed3a24266d..750949e837 100644 --- a/crates/horizon-shaders/Cargo.toml +++ b/crates/horizon-shaders/Cargo.toml @@ -7,6 +7,7 @@ rust-version = "1.85" description = "Shader packs (the shaders/ folder format): reading packs, their .properties and .lang files, and later their GLSL" [dependencies] +horizon-core = { path = "../horizon-core" } zip = { version = "2", default-features = false, features = ["deflate"] } [lints] diff --git a/crates/horizon-shaders/src/condition.rs b/crates/horizon-shaders/src/condition.rs new file mode 100644 index 0000000000..b911eb9855 --- /dev/null +++ b/crates/horizon-shaders/src/condition.rs @@ -0,0 +1,265 @@ +//! The integer arithmetic of `#if` and `#elif` (spec 1.5, GLSL 4.60 §3.3): `defined`, integer +//! literals, macros expanded to their values, and the C operators with C precedence. +//! An identifier that is not a macro counts as 0, as in C: GLSL would reject the line, and a +//! pack would then lose a whole section of its settings over one unknown name. + +use crate::Macros; +use crate::macros::{is_ident, is_ident_start}; + +/// The value of a condition; `Err` says what is wrong with it. +pub fn eval_condition(expr: &str, macros: &Macros) -> Result { + let tokens = tokens(&resolve_defined(expr, macros)?, macros)?; + let mut parser = Parser { + tokens: &tokens, + at: 0, + }; + let value = parser.binary(0)?; + match parser.tokens.get(parser.at) { + None => Ok(value), + Some(t) => Err(format!("unexpected `{}`", t.text())), + } +} + +/// `defined X` and `defined(X)` become `1` or `0` before anything is expanded. +fn resolve_defined(expr: &str, macros: &Macros) -> Result { + let mut out = String::with_capacity(expr.len()); + let mut rest = expr; + while let Some(at) = find_word(rest, "defined") { + out.push_str(&rest[..at]); + let after = rest[at + "defined".len()..].trim_start(); + let (inner, tail) = match after.strip_prefix('(') { + Some(open) => { + let close = open.find(')').ok_or("`defined(` without `)`")?; + (open[..close].trim(), &open[close + 1..]) + } + None => { + let len = after.find(|c: char| !is_ident(c)).unwrap_or(after.len()); + (&after[..len], &after[len..]) + } + }; + if inner.is_empty() || !inner.starts_with(is_ident_start) || !inner.chars().all(is_ident) { + return Err("`defined` needs a macro name".into()); + } + out.push_str(if macros.is_defined(inner) { + " 1 " + } else { + " 0 " + }); + rest = tail; + } + out.push_str(rest); + Ok(out) +} + +/// Where `word` starts in `text` as a whole identifier. +fn find_word(text: &str, word: &str) -> Option { + let mut from = 0; + while let Some(i) = text[from..].find(word).map(|i| i + from) { + let before = text[..i].chars().next_back().is_some_and(is_ident); + let after = text[i + word.len()..].chars().next().is_some_and(is_ident); + if !before && !after { + return Some(i); + } + from = i + word.len(); + } + None +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Token { + Num(i64), + Op(&'static str), +} + +impl Token { + fn text(&self) -> String { + match self { + Token::Num(n) => n.to_string(), + Token::Op(op) => (*op).to_owned(), + } + } +} + +const OPS: [&str; 22] = [ + "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "(", ")", "!", "~", "+", "-", "*", "/", "%", + "<", ">", "&", "^", "|", +]; + +fn tokens(expr: &str, macros: &Macros) -> Result, String> { + let expanded = macros.expand(expr); + let mut out = Vec::new(); + let mut rest = expanded.trim_start(); + while let Some(c) = rest.chars().next() { + if c.is_ascii_digit() { + let len = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + if rest[len..].starts_with('.') { + return Err(format!("`{}`: `#if` takes integers only", &rest[..len + 1])); + } + out.push(Token::Num(int_literal(&rest[..len])?)); + rest = &rest[len..]; + } else if is_ident_start(c) { + // Not a macro: 0, as the C preprocessor counts it. + let len = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len()); + out.push(Token::Num(0)); + rest = &rest[len..]; + } else { + let op = OPS + .iter() + .find(|op| rest.starts_with(**op)) + .ok_or_else(|| format!("unexpected `{c}`"))?; + out.push(Token::Op(op)); + rest = &rest[op.len()..]; + } + rest = rest.trim_start(); + } + Ok(out) +} + +/// Decimal, `0x` hexadecimal or leading-zero octal, with an optional `u` suffix. +fn int_literal(text: &str) -> Result { + let digits = text.strip_suffix(['u', 'U']).unwrap_or(text); + let parsed = if let Some(hex) = digits.strip_prefix("0x").or(digits.strip_prefix("0X")) { + i64::from_str_radix(hex, 16) + } else if digits.len() > 1 && digits.starts_with('0') { + i64::from_str_radix(&digits[1..], 8) + } else { + digits.parse() + }; + parsed.map_err(|_| format!("`{text}` is not an integer")) +} + +struct Parser<'a> { + tokens: &'a [Token], + at: usize, +} + +/// Binary operators from the loosest to the tightest (GLSL §5.1 / §3.3). +const LEVELS: [&[&str]; 10] = [ + &["||"], + &["&&"], + &["|"], + &["^"], + &["&"], + &["==", "!="], + &["<", ">", "<=", ">="], + &["<<", ">>"], + &["+", "-"], + &["*", "/", "%"], +]; + +impl Parser<'_> { + fn next(&mut self) -> Option { + let t = self.tokens.get(self.at).copied(); + self.at += 1; + t + } + + fn binary(&mut self, level: usize) -> Result { + if level == LEVELS.len() { + return self.unary(); + } + let mut left = self.binary(level + 1)?; + while let Some(&Token::Op(op)) = self.tokens.get(self.at) { + if !LEVELS[level].contains(&op) { + break; + } + self.at += 1; + let right = self.binary(level + 1)?; + left = apply(op, left, right)?; + } + Ok(left) + } + + fn unary(&mut self) -> Result { + match self.next() { + Some(Token::Num(n)) => Ok(n), + Some(Token::Op("(")) => { + let value = self.binary(0)?; + match self.next() { + Some(Token::Op(")")) => Ok(value), + _ => Err("missing `)`".into()), + } + } + Some(Token::Op("-")) => Ok(self.unary()?.wrapping_neg()), + Some(Token::Op("+")) => self.unary(), + Some(Token::Op("!")) => Ok((self.unary()? == 0) as i64), + Some(Token::Op("~")) => Ok(!self.unary()?), + Some(t) => Err(format!("unexpected `{}`", t.text())), + None => Err("the condition ends too early".into()), + } + } +} + +fn apply(op: &str, a: i64, b: i64) -> Result { + Ok(match op { + "||" => (a != 0 || b != 0) as i64, + "&&" => (a != 0 && b != 0) as i64, + "|" => a | b, + "^" => a ^ b, + "&" => a & b, + "==" => (a == b) as i64, + "!=" => (a != b) as i64, + "<" => (a < b) as i64, + ">" => (a > b) as i64, + "<=" => (a <= b) as i64, + ">=" => (a >= b) as i64, + "<<" => a.wrapping_shl(b as u32), + ">>" => a.wrapping_shr(b as u32), + "+" => a.wrapping_add(b), + "-" => a.wrapping_sub(b), + "*" => a.wrapping_mul(b), + "/" | "%" if b == 0 => return Err("division by zero".into()), + "/" => a.wrapping_div(b), + _ => a.wrapping_rem(b), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(expr: &str) -> Result { + let mut m = Macros::standard(); + m.define("SHADOW_RES", "2048"); + m.define("WAVES", ""); + m.define("QUALITY", "SHADOW_RES / 1024"); + eval_condition(expr, &m) + } + + #[test] + fn precedence_and_operators_follow_c() { + assert_eq!(eval("1 + 2 * 3"), Ok(7)); + assert_eq!(eval("(1 + 2) * 3"), Ok(9)); + assert_eq!(eval("1 << 2 + 1"), Ok(8)); + assert_eq!(eval("6 & 3 == 3"), Ok(0)); + assert_eq!(eval("1 || 0 && 0"), Ok(1)); + assert_eq!(eval("-7 / 2"), Ok(-3)); + assert_eq!(eval("-7 % 2"), Ok(-1)); + assert_eq!(eval("!0 + ~0"), Ok(0)); + assert_eq!(eval("0x10 + 010 + 3u"), Ok(27)); + } + + #[test] + fn macros_and_defined() { + assert_eq!(eval("MC_VERSION >= 11300"), Ok(1)); + assert_eq!(eval("MC_VERSION <= 12111"), Ok(0)); + assert_eq!(eval("QUALITY == 2"), Ok(1)); + assert_eq!(eval("defined WAVES && defined(SHADOW_RES)"), Ok(1)); + assert_eq!(eval("!defined NOPE"), Ok(1)); + assert_eq!(eval("NOPE == 0"), Ok(1)); + assert_eq!(eval("undefinedFeature"), Ok(0)); + } + + #[test] + fn bad_conditions_say_why() { + assert!(eval("1 / 0").is_err()); + assert!(eval("SHADOW_RES == 0.5").is_err()); + assert!(eval("(1 + 2").is_err()); + assert!(eval("1 2").is_err()); + assert!(eval("").is_err()); + assert!(eval("defined").is_err()); + assert!(eval("1 @ 2").is_err()); + } +} diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 1c9b46dd1d..e7ccb26d69 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -5,11 +5,16 @@ //! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1). //! - [`discover`]: the packs in a `shaderpacks` folder. //! - [`Lang`]: the pack's translated names (8.4). +//! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). +mod condition; mod discover; mod lang; +mod macros; mod pack; +pub use condition::eval_condition; pub use discover::{Found, discover}; pub use lang::Lang; +pub use macros::{MC_VERSION, Macros}; pub use pack::{Pack, PackError}; diff --git a/crates/horizon-shaders/src/macros.rs b/crates/horizon-shaders/src/macros.rs new file mode 100644 index 0000000000..f3a53722f2 --- /dev/null +++ b/crates/horizon-shaders/src/macros.rs @@ -0,0 +1,131 @@ +//! Object-like macros: the ones packs test in `#if` lines and use inside property values +//! (spec 1.5, 1.5.1). The GLSL preprocessor's function-like macros are not needed here. + +use std::collections::BTreeMap; + +/// `MC_VERSION` of the version Horizon plays: 26.3 → 260300. +pub const MC_VERSION: u32 = mc_version(horizon_core::version::VERSION_ID); + +/// The documented rule, `major * 10000 + minor * 100 + patch` (spec 1.5.1, OQ-1): `1.21` → 12100, +/// `1.7.10` → 10710. Parts that are not digits count as 0. +pub const fn mc_version(id: &str) -> u32 { + let bytes = id.as_bytes(); + let (mut parts, mut part, mut i) = ([0u32; 3], 0, 0); + while i < bytes.len() && part < 3 { + match bytes[i] { + b'.' => part += 1, + b @ b'0'..=b'9' => parts[part] = parts[part] * 10 + (b - b'0') as u32, + _ => break, + } + i += 1; + } + parts[0] * 10000 + parts[1] * 100 + parts[2] +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Macros(BTreeMap); + +impl Macros { + /// The standard macros that need no renderer: `MC_VERSION` and this system's `MC_OS_*`. + /// The graphics ones arrive with the renderer. + pub fn standard() -> Self { + let mut m = Self::default(); + m.define("MC_VERSION", MC_VERSION.to_string()); + let os = match std::env::consts::OS { + "windows" => "MC_OS_WINDOWS", + "macos" => "MC_OS_MAC", + "linux" => "MC_OS_LINUX", + _ => "MC_OS_OTHER", + }; + m.define(os, ""); + m + } + + pub fn define(&mut self, name: impl Into, value: impl Into) { + self.0.insert(name.into(), value.into()); + } + + pub fn undef(&mut self, name: &str) { + self.0.remove(name); + } + + pub fn is_defined(&self, name: &str) -> bool { + self.0.contains_key(name) + } + + pub fn get(&self, name: &str) -> Option<&str> { + self.0.get(name).map(String::as_str) + } + + /// `text` with every identifier that names a macro replaced by its value, repeatedly; a macro + /// is not expanded again inside its own expansion, so `A` defined as `A + 1` stops. + pub fn expand(&self, text: &str) -> String { + self.expand_hiding(text, &mut Vec::new()) + } + + fn expand_hiding<'a>(&'a self, text: &str, hidden: &mut Vec<&'a str>) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(is_ident_start) { + out.push_str(&rest[..start]); + // A digit run glued to letters (`1e5`, `0x1F`) is a number, not a name. + let glued = out + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '.'); + let len = rest[start..] + .find(|c: char| !is_ident(c)) + .unwrap_or(rest.len() - start); + let word = &rest[start..start + len]; + match self.0.get_key_value(word) { + Some((name, value)) if !glued && !hidden.contains(&name.as_str()) => { + hidden.push(name); + out.push_str(&self.expand_hiding(value, hidden)); + hidden.pop(); + } + _ => out.push_str(word), + } + rest = &rest[start + len..]; + } + out.push_str(rest); + out + } +} + +pub(crate) fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +pub(crate) fn is_ident(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_follow_the_documented_rule() { + assert_eq!(mc_version("1.21"), 12100); + assert_eq!(mc_version("1.21.0"), 12100); + assert_eq!(mc_version("1.7.10"), 10710); + assert_eq!(mc_version("26.3"), 260300); + assert_eq!(MC_VERSION, 260300); + assert_eq!(Macros::standard().get("MC_VERSION"), Some("260300")); + } + + #[test] + fn expansion_replaces_whole_words_and_stops_on_recursion() { + let mut m = Macros::default(); + m.define("RES", "SIZE"); + m.define("SIZE", "512"); + m.define("LOOP", "LOOP + 1"); + assert_eq!(m.expand("RES RES 0.5"), "512 512 0.5"); + assert_eq!( + m.expand("RESOLUTION colortex4 LOOP"), + "RESOLUTION colortex4 LOOP + 1" + ); + m.define("e5", "x"); + assert_eq!(m.expand("1e5"), "1e5"); + } +} From 397d529db72a17a1def83e1d7c50e9f866beb69e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:42:33 +0200 Subject: [PATCH 0005/2756] Shader packs, clean room 3: .properties files with conditional directives shaders.properties and the id files (block, item, entity, dimension): key=value lines in file order with their line numbers, # comments, a value continued on the next line by a trailing backslash, and the conditional directives evaluated with the standard and option macros. A # line is a directive only when its first word is one, so a commented-out key such as #ifdefault=1 stays a comment. #define and #undef apply to the lines after them in every one of these files: a public pack defines a macro inside #ifdef MC_OS_MAC in its shaders.properties and tests it further down. A later duplicate key replaces the earlier one; broken conditionals, lines that are not key=value and duplicates are kept as notes for the pack's log. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/lib.rs | 3 + crates/horizon-shaders/src/properties.rs | 279 +++++++++++++++++++++ crates/horizon-shaders/tests/real_packs.rs | 30 ++- 3 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 crates/horizon-shaders/src/properties.rs diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index e7ccb26d69..bdfef7067f 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -4,6 +4,7 @@ //! //! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1). //! - [`discover`]: the packs in a `shaderpacks` folder. +//! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). @@ -12,9 +13,11 @@ mod discover; mod lang; mod macros; mod pack; +mod properties; pub use condition::eval_condition; pub use discover::{Found, discover}; pub use lang::Lang; pub use macros::{MC_VERSION, Macros}; pub use pack::{Pack, PackError}; +pub use properties::{Entry, Note, Properties}; diff --git a/crates/horizon-shaders/src/properties.rs b/crates/horizon-shaders/src/properties.rs new file mode 100644 index 0000000000..fc3f00c816 --- /dev/null +++ b/crates/horizon-shaders/src/properties.rs @@ -0,0 +1,279 @@ +//! `.properties` files: `shaders.properties` (spec 3.1) and the id files `block.properties`, +//! `item.properties`, `entity.properties` (4.1). `key=value` lines, `#` comments, a trailing `\` +//! continuing a value on the next line, and conditional directives evaluated with the +//! standard and option macros (1.5 item 2). `#define` and `#undef` work in every one of them: +//! a public pack defines a macro in its `shaders.properties` and tests it further down. +//! No other escape is read: values are names, numbers and expressions. A later duplicate key +//! replaces the earlier one, with a note. + +use crate::{Macros, eval_condition}; + +/// One `key=value`, with the line it starts on (from 1). +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub key: String, + pub value: String, + pub line: u32, +} + +/// Something wrong with a line, for the pack's log. +#[derive(Clone, Debug, PartialEq)] +pub struct Note { + pub line: u32, + pub message: String, +} + +#[derive(Debug, Default, PartialEq)] +pub struct Properties { + /// In file order; a key appears once (a later duplicate replaces the earlier one). + pub entries: Vec, + pub notes: Vec, +} + +/// One branch level of `#if` ... `#endif`. +struct Branch { + /// This branch's lines are read. + on: bool, + /// A branch of this `#if` was already taken (the rest are skipped). + taken: bool, + /// The enclosing level is read. + outer: bool, + seen_else: bool, +} + +impl Properties { + /// Reads `text`; `#define`/`#undef` change the macros for the lines after them only. + pub fn parse(text: &str, macros: &Macros) -> Self { + let mut out = Self::default(); + let mut macros = macros.clone(); + let mut branches: Vec = Vec::new(); + let mut lines = text.lines().zip(1u32..); + while let Some((raw, number)) = lines.next() { + let line = raw.trim(); + let on = branches.last().is_none_or(|b| b.on); + if let Some(directive) = line.strip_prefix('#') { + out.directive(directive.trim_start(), number, &mut branches, &mut macros); + continue; + } + // Continuation lines belong to their entry even inside a skipped branch. + let mut logical = line.to_owned(); + while ends_with_continuation(&logical) { + logical.pop(); + match lines.next() { + Some((next, _)) => logical.push_str(next.trim()), + None => break, + } + } + if !on || logical.is_empty() { + continue; + } + match logical.split_once('=') { + Some((key, value)) if !key.trim().is_empty() => out.insert(Entry { + key: key.trim().to_owned(), + value: value.trim().to_owned(), + line: number, + }), + _ => out.note(number, "not a key=value line"), + } + } + if !branches.is_empty() { + out.note(text.lines().count() as u32, "`#if` without `#endif`"); + } + out + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.entry(key).map(|e| e.value.as_str()) + } + + pub fn entry(&self, key: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.key == key) + } + + fn insert(&mut self, entry: Entry) { + if let Some(at) = self.entries.iter().position(|e| e.key == entry.key) { + let first = self.entries.remove(at).line; + self.note( + entry.line, + &format!( + "`{}` is set again (first on line {first}); this one counts", + entry.key + ), + ); + } + self.entries.push(entry); + } + + fn note(&mut self, line: u32, message: &str) { + self.notes.push(Note { + line, + message: message.to_owned(), + }); + } + + /// A line starting with `#`. It is a directive only when its first word is one; anything + /// else (`# text`, `#commented.key=1`, `#ifdefault`) is a comment. + fn directive( + &mut self, + line: &str, + number: u32, + branches: &mut Vec, + macros: &mut Macros, + ) { + let word_len = line + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(line.len()); + let (word, rest) = line.split_at(word_len); + if !(rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//")) { + return; + } + let rest = rest.split("//").next().unwrap_or("").trim(); + let on = branches.last().is_none_or(|b| b.on); + match word { + "if" | "ifdef" | "ifndef" => { + let value = on && self.condition(word, rest, number, macros); + branches.push(Branch { + on: value, + taken: value, + outer: on, + seen_else: false, + }); + } + "elif" | "else" => { + let Some(b) = branches.last_mut() else { + return self.note(number, &format!("`#{word}` without `#if`")); + }; + if b.seen_else { + return self.note(number, &format!("`#{word}` after `#else`")); + } + let take = b.outer && !b.taken; + b.seen_else = word == "else"; + b.on = take && (word == "else" || self.condition("if", rest, number, macros)); + b.taken |= b.on; + } + "endif" => { + if branches.pop().is_none() { + self.note(number, "`#endif` without `#if`"); + } + } + "define" if on => { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + macros.define(name, value.trim()); + } + "undef" if on => macros.undef(rest), + _ => {} + } + } + + fn condition(&mut self, word: &str, rest: &str, number: u32, macros: &Macros) -> bool { + let name = rest.split_whitespace().next().unwrap_or(""); + let result = match word { + "ifdef" => Ok(macros.is_defined(name) as i64), + "ifndef" => Ok(!macros.is_defined(name) as i64), + _ => eval_condition(rest, macros), + }; + result.unwrap_or_else(|e| { + self.note(number, &format!("`#{word} {rest}`: {e}; read as false")); + 0 + }) != 0 + } +} + +/// An odd number of trailing backslashes: the last one joins the next line (`\\` is a literal). +fn ends_with_continuation(line: &str) -> bool { + line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(text: &str) -> Properties { + let mut m = Macros::standard(); + m.define("SHADOWS", ""); + m.define("QUALITY", "2"); + Properties::parse(text, &m) + } + + fn pairs(p: &Properties) -> Vec<(&str, &str)> { + p.entries + .iter() + .map(|e| (e.key.as_str(), e.value.as_str())) + .collect() + } + + #[test] + fn keys_values_and_comments() { + let p = + read("# a comment\n\n sun = false\n#commented.key=1\nscreen=A B [C]\r\nbad line\n"); + assert_eq!(pairs(&p), [("sun", "false"), ("screen", "A B [C]")]); + assert_eq!(p.entry("screen").unwrap().line, 5); + assert_eq!( + p.notes, + [Note { + line: 6, + message: "not a key=value line".into() + }] + ); + } + + #[test] + fn continuation_lines_join() { + let p = read("screen=A B \\\n C D\\\n E\nsliders=X\nliteral=a\\\\\nnext=1"); + assert_eq!( + pairs(&p), + [ + ("screen", "A B C DE"), + ("sliders", "X"), + ("literal", "a\\\\"), + ("next", "1") + ] + ); + assert_eq!(p.entry("sliders").unwrap().line, 4); + } + + #[test] + fn conditionals_pick_one_branch() { + let p = read( + "#if MC_VERSION >= 11300\nnew=1\n#else\nold=1\n#endif\n\ + #ifdef SHADOWS\n#if QUALITY == 1\nq=1\n#elif QUALITY == 2\nq=2\n#elif QUALITY >= 2\nq=3\n#endif\n#endif\n\ + #ifndef SHADOWS\nnone=1\n#elif 0\nnever=1\n#else\nfallback=1\n#endif\n# ifdef SHADOWS // trailing comment\nspaced=1\n#endif", + ); + assert_eq!( + pairs(&p), + [("new", "1"), ("q", "2"), ("fallback", "1"), ("spaced", "1")] + ); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn comment_lines_are_not_directives() { + let p = read("#ifdefault=1\n#iffy\n#endiff\n#else-where\nkey=1"); + assert_eq!(pairs(&p), [("key", "1")]); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn defines_apply_to_the_lines_after_them() { + let text = "#ifdef MC_OS_NONE\n#define WATER 0\n#endif\n#define WATER 8\n\ + #if WATER == 8\nblock.8=minecraft:water\n#endif\n#undef WATER\n#ifdef WATER\nno=1\n#endif"; + let p = Properties::parse(text, &Macros::default()); + assert_eq!(pairs(&p), [("block.8", "minecraft:water")]); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn duplicate_keys_keep_the_last() { + let p = read("a=1\nb=2\na=3"); + assert_eq!(pairs(&p), [("b", "2"), ("a", "3")]); + assert_eq!(p.notes[0].line, 3); + } + + #[test] + fn broken_conditionals_are_noted() { + let p = read("#endif\n#else\n#if 1 +\nx=1\n#endif\n#if 1\n#else\n#else\n"); + let lines: Vec = p.notes.iter().map(|n| n.line).collect(); + assert_eq!(lines, [1, 2, 3, 8, 8]); + assert!(p.entries.is_empty()); + } +} diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index b35aef5198..e0ad38c250 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::time::Instant; -use horizon_shaders::{Lang, Pack, discover}; +use horizon_shaders::{Lang, Macros, Pack, Properties, discover}; #[test] #[ignore = "needs HORIZON_TEST_SHADER_PACKS"] @@ -22,21 +22,37 @@ fn real_packs_read() { for f in found.iter().filter(|f| f.problem.is_none()) { let start = Instant::now(); let pack = Pack::open(&f.path).unwrap(); - let properties = pack.read_text("shaders.properties"); + let macros = Macros::standard(); + let shaders = Properties::parse( + &pack.read_text("shaders.properties").unwrap_or_default(), + ¯os, + ); + let ids: Vec = ["block", "item", "entity", "dimension"] + .iter() + .filter_map(|n| pack.read_text(&format!("{n}.properties"))) + .map(|t| Properties::parse(&t, ¯os)) + .collect(); let lang = Lang::load(&pack, "en_us"); + let elapsed = start.elapsed(); println!( - "{}: {} files at the root, shaders.properties {} bytes, lang {}, {:.2} ms", + "{}: shaders.properties {} keys ({} notes), {} id files {} keys ({} notes), lang {}, {:.2} ms", f.name, - pack.list("").len(), - properties.as_ref().map_or(0, String::len), + shaders.entries.len(), + shaders.notes.len(), + ids.len(), + ids.iter().map(|p| p.entries.len()).sum::(), + ids.iter().map(|p| p.notes.len()).sum::(), if lang == Lang::default() { "none" } else { "read" }, - start.elapsed().as_secs_f64() * 1000.0 + elapsed.as_secs_f64() * 1000.0 ); - assert!(properties.is_some(), "{}: no shaders.properties", f.name); + for n in &shaders.notes { + println!(" shaders.properties:{}: {}", n.line, n.message); + } + assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } assert!(read > 0, "no packs under {}", dir.display()); From 352645b41711436306bc30e41e139b2d3809005a Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:07:31 +0500 Subject: [PATCH 0006/2756] Predict block placement on the client, so fast bridging stops losing clicks MultiPlayerGameMode.useItemOn places the block on the client's level at once, kept under the use's sequence until BlockChangedAck. Without it the next fast click aimed past the block still on its way from the server, the server refused it, and the arm swung for nothing. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 56 +++++++++++++++++++----- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index da0def0fb1..e54b33b02b 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3862,11 +3862,17 @@ impl Game<'_> { self.predict_item_on_block(stack, &hit); } if held_block { - if reacts - || held - .as_ref() - .is_some_and(|stack| self.remote_can_place(&hit, stack)) - { + let placed = if reacts { + None + } else { + held.as_ref() + .and_then(|stack| self.remote_can_place(&hit, stack)) + }; + if let Some(placed) = &placed { + self.predict_place(placed, seq); + self.with_hand(hand, Game::consume_held); + } + if reacts || placed.is_some() { self.with_hand(hand, Game::swing_arm); } // BlockItem.place returns FAIL when placement is rejected: @@ -3997,12 +4003,41 @@ impl Game<'_> { }) } - /// Run BlockItem.place on a disposable level before predicting its swing. - /// The real level supplies collision shapes and building-blocking entities; - /// block writes, random draws, scheduled ticks and events stay in the preview. - fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool { + /// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item: + /// the blocks `BlockItem.place` set are placed on this client's level at + /// once, each kept as predicted under the use's sequence until the server + /// acknowledges it (a placement the server refused goes back). Without + /// this, the next click of a fast bridge aims past the block still on its + /// way from the server, and the server refuses it. + fn predict_place( + &mut self, + changes: &[(horizon_core::position::BlockPos, BlockStateId)], + seq: i32, + ) { + for &(p, state) in changes { + let pos = [p.x, p.y, p.z]; + let before = self.world.block(p.x, p.y, p.z); + // BlockStatePredictionHandler.retainKnownServerState. + if let Some(r) = self.remote.as_mut() { + r.predictions + .entry(pos) + .and_modify(|e| e.0 = seq) + .or_insert((seq, before.0 as i32)); + } + self.set_block(pos, state.0 as i32); + } + } + + /// Run BlockItem.place on a disposable level: the blocks it set, if it + /// placed. The real level supplies collision shapes and building-blocking + /// entities; random draws, scheduled ticks and events stay in the preview. + fn remote_can_place( + &mut self, + hit: &super::Hit, + stack: &ItemStack, + ) -> Option> { if !self.may_build() { - return false; + return None; } let ctx = horizon_world::level::PlaceContext { clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]), @@ -4025,6 +4060,7 @@ impl Game<'_> { ), horizon_world::level::PlaceOutcome::Placed { .. } ) + .then(|| level.changed.into_iter().collect()) }) } From 4c65ed2f5c6c4e089cf4f0bec4b32ab97004e4bc Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:09:07 +0200 Subject: [PATCH 0007/2756] Shader packs, clean room 4: function-like macros and the standard macros The macro engine the GLSL preprocessor needs, shared with the .properties reader: function-like macros take their arguments (expanded first, raw beside ##, which pastes) when a ( follows their name; a macro is still not expanded inside its own expansion. Text is cut into preprocessing tokens, so numbers such as 1e5 or 0x1F are never mistaken for names. The #if/#elif/#else/#endif state moves out of the .properties reader so the preprocessor uses the same code. Standard macros: MC_GL_VERSION and MC_GLSL_VERSION 460, the 24 MC_RENDER_STAGE_* as ascending integers in draw order, render and shadow quality 1.0, MC_HAND_DEPTH 0.125, MAX_COLOR_BUFFERS 16. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/condition.rs | 104 ++++++++++ crates/horizon-shaders/src/lib.rs | 2 +- crates/horizon-shaders/src/macros.rs | 242 ++++++++++++++++++++--- crates/horizon-shaders/src/properties.rs | 98 +++------ 4 files changed, 343 insertions(+), 103 deletions(-) diff --git a/crates/horizon-shaders/src/condition.rs b/crates/horizon-shaders/src/condition.rs index b911eb9855..c476bb6632 100644 --- a/crates/horizon-shaders/src/condition.rs +++ b/crates/horizon-shaders/src/condition.rs @@ -20,6 +20,110 @@ pub fn eval_condition(expr: &str, macros: &Macros) -> Result { } } +/// The text after a line's `#`, split into the directive word and the rest without its `//` +/// comment. `None` when the first word is followed by something other than white space, `//` or +/// the end of the line (`#ifdefault=1` is a comment, not `#ifdef`). +pub(crate) fn split_directive(after_hash: &str) -> Option<(&str, &str)> { + let line = after_hash.trim_start(); + let len = line + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(line.len()); + let (word, rest) = line.split_at(len); + let ok = rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//"); + ok.then(|| (word, rest.split("//").next().unwrap_or("").trim())) +} + +/// One level of `#if` ... `#endif`. +struct Branch { + /// This branch's lines are read. + on: bool, + /// A branch of this `#if` was already taken (the rest are skipped). + taken: bool, + /// The enclosing level is read. + outer: bool, + seen_else: bool, +} + +/// The state of nested conditional directives. +#[derive(Default)] +pub(crate) struct Conditionals(Vec); + +impl Conditionals { + /// Lines here are read. + pub(crate) fn active(&self) -> bool { + self.0.last().is_none_or(|b| b.on) + } + + /// An `#if` is still open. + pub(crate) fn open(&self) -> bool { + !self.0.is_empty() + } + + /// Applies `#word rest` when it is a conditional directive (`Ok(false)` when it is not). A + /// condition that can't be evaluated reads as false and returns why. + pub(crate) fn apply( + &mut self, + word: &str, + rest: &str, + macros: &Macros, + ) -> Result { + let on = self.active(); + match word { + "if" | "ifdef" | "ifndef" => { + let value = if on { + test(word, rest, macros) + } else { + Ok(false) + }; + let v = *value.as_ref().unwrap_or(&false); + self.0.push(Branch { + on: v, + taken: v, + outer: on, + seen_else: false, + }); + value.map(|_| true) + } + "elif" | "else" => { + let b = self + .0 + .last_mut() + .ok_or_else(|| format!("`#{word}` without `#if`"))?; + if b.seen_else { + return Err(format!("`#{word}` after `#else`")); + } + b.seen_else = word == "else"; + let take = b.outer && !b.taken; + let value = match word { + "else" => Ok(take), + _ if take => test("if", rest, macros), + _ => Ok(false), + }; + b.on = *value.as_ref().unwrap_or(&false); + b.taken |= b.on; + value.map(|_| true) + } + "endif" => self + .0 + .pop() + .map(|_| true) + .ok_or_else(|| "`#endif` without `#if`".into()), + _ => Ok(false), + } + } +} + +fn test(word: &str, rest: &str, macros: &Macros) -> Result { + let name = rest.split_whitespace().next().unwrap_or(""); + match word { + "ifdef" => Ok(macros.is_defined(name)), + "ifndef" => Ok(!macros.is_defined(name)), + _ => eval_condition(rest, macros) + .map(|v| v != 0) + .map_err(|e| format!("`#{word} {rest}`: {e}; read as false")), + } +} + /// `defined X` and `defined(X)` become `1` or `0` before anything is expanded. fn resolve_defined(expr: &str, macros: &Macros) -> Result { let mut out = String::with_capacity(expr.len()); diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index bdfef7067f..05e51a53df 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -18,6 +18,6 @@ mod properties; pub use condition::eval_condition; pub use discover::{Found, discover}; pub use lang::Lang; -pub use macros::{MC_VERSION, Macros}; +pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; pub use properties::{Entry, Note, Properties}; diff --git a/crates/horizon-shaders/src/macros.rs b/crates/horizon-shaders/src/macros.rs index f3a53722f2..d8a184f413 100644 --- a/crates/horizon-shaders/src/macros.rs +++ b/crates/horizon-shaders/src/macros.rs @@ -22,12 +22,48 @@ pub const fn mc_version(id: &str) -> u32 { parts[0] * 10000 + parts[1] * 100 + parts[2] } +#[derive(Clone, Debug, PartialEq)] +struct Macro { + /// `None` for an object-like macro; the parameter names of a function-like one. + params: Option>, + body: String, +} + #[derive(Clone, Debug, Default, PartialEq)] -pub struct Macros(BTreeMap); +pub struct Macros(BTreeMap); + +/// `MC_RENDER_STAGE_*` in the order the stages draw (spec 5.4); each is its index (OQ-7). +pub const RENDER_STAGES: [&str; 24] = [ + "NONE", + "SKY", + "SUNSET", + "CUSTOM_SKY", + "SUN", + "MOON", + "STARS", + "VOID", + "TERRAIN_SOLID", + "TERRAIN_CUTOUT_MIPPED", + "TERRAIN_CUTOUT", + "ENTITIES", + "BLOCK_ENTITIES", + "DESTROY", + "OUTLINE", + "DEBUG", + "HAND_SOLID", + "TERRAIN_TRANSLUCENT", + "TRIPWIRE", + "PARTICLES", + "CLOUDS", + "RAIN_SNOW", + "WORLD_BORDER", + "HAND_TRANSLUCENT", +]; impl Macros { - /// The standard macros that need no renderer: `MC_VERSION` and this system's `MC_OS_*`. - /// The graphics ones arrive with the renderer. + /// The standard macros that need no renderer (spec 1.5.1): `MC_VERSION`, this system's + /// `MC_OS_*`, the GL and GLSL versions Horizon's translation accepts, the render stages and + /// the host settings at their defaults. The GPU vendor and family arrive with the renderer. pub fn standard() -> Self { let mut m = Self::default(); m.define("MC_VERSION", MC_VERSION.to_string()); @@ -38,11 +74,32 @@ impl Macros { _ => "MC_OS_OTHER", }; m.define(os, ""); + m.define("MC_GL_VERSION", "460"); + m.define("MC_GLSL_VERSION", "460"); + for (i, stage) in RENDER_STAGES.iter().enumerate() { + m.define(format!("MC_RENDER_STAGE_{stage}"), i.to_string()); + } + m.define("MC_RENDER_QUALITY", "1.0"); + m.define("MC_SHADOW_QUALITY", "1.0"); + m.define("MC_HAND_DEPTH", "0.125"); + m.define("MAX_COLOR_BUFFERS", "16"); m } pub fn define(&mut self, name: impl Into, value: impl Into) { - self.0.insert(name.into(), value.into()); + let body = value.into(); + self.0.insert(name.into(), Macro { params: None, body }); + } + + pub fn define_function(&mut self, name: &str, params: Vec, body: &str) { + let body = body.to_owned(); + self.0.insert( + name.to_owned(), + Macro { + params: Some(params), + body, + }, + ); } pub fn undef(&mut self, name: &str) { @@ -53,43 +110,158 @@ impl Macros { self.0.contains_key(name) } + /// The value of an object-like macro. pub fn get(&self, name: &str) -> Option<&str> { - self.0.get(name).map(String::as_str) + self.0 + .get(name) + .filter(|m| m.params.is_none()) + .map(|m| m.body.as_str()) } - /// `text` with every identifier that names a macro replaced by its value, repeatedly; a macro - /// is not expanded again inside its own expansion, so `A` defined as `A + 1` stops. + /// `text` with every macro expanded, repeatedly; a macro is not expanded again inside its own + /// expansion, so `A` defined as `A + 1` stops. Function-like macros take their arguments + /// (expanded first, raw beside `##`, which pastes) when a `(` follows their name. pub fn expand(&self, text: &str) -> String { self.expand_hiding(text, &mut Vec::new()) } fn expand_hiding<'a>(&'a self, text: &str, hidden: &mut Vec<&'a str>) -> String { let mut out = String::with_capacity(text.len()); - let mut rest = text; - while let Some(start) = rest.find(is_ident_start) { - out.push_str(&rest[..start]); - // A digit run glued to letters (`1e5`, `0x1F`) is a number, not a name. - let glued = out - .chars() - .next_back() - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '.'); - let len = rest[start..] - .find(|c: char| !is_ident(c)) - .unwrap_or(rest.len() - start); - let word = &rest[start..start + len]; - match self.0.get_key_value(word) { - Some((name, value)) if !glued && !hidden.contains(&name.as_str()) => { - hidden.push(name); - out.push_str(&self.expand_hiding(value, hidden)); - hidden.pop(); - } - _ => out.push_str(word), + let tokens = lex(text); + let mut i = 0; + while i < tokens.len() { + let word = tokens[i]; + i += 1; + let Some((name, m)) = self.0.get_key_value(word) else { + out.push_str(word); + continue; + }; + if hidden.contains(&name.as_str()) { + out.push_str(word); + continue; } - rest = &rest[start + len..]; + let body = match &m.params { + None => m.body.clone(), + Some(params) => { + let Some((args, next)) = arguments(&tokens, i) else { + out.push_str(word); + continue; + }; + i = next; + self.substitute(params, &args, &m.body, hidden) + } + }; + hidden.push(name); + out.push_str(&self.expand_hiding(&body, hidden)); + hidden.pop(); } - out.push_str(rest); out } + + /// A function-like macro's body with its parameters replaced. + fn substitute<'a>( + &'a self, + params: &[String], + args: &[String], + body: &str, + hidden: &mut Vec<&'a str>, + ) -> String { + let tokens = lex(body); + let pasted = |at: usize, step: isize| { + let mut j = at as isize + step; + while (0..tokens.len() as isize).contains(&j) && tokens[j as usize].trim().is_empty() { + j += step; + } + (0..tokens.len() as isize).contains(&j) && tokens[j as usize] == "##" + }; + let mut out = String::new(); + for (at, token) in tokens.iter().enumerate() { + match params.iter().position(|p| p == token) { + Some(k) => { + let arg = args.get(k).map_or("", |a| a.trim()); + if pasted(at, -1) || pasted(at, 1) { + out.push_str(arg); + } else { + out.push_str(&self.expand_hiding(arg, hidden)); + } + } + None => out.push_str(token), + } + } + // `a ## b` → `ab`. + let mut joined = String::with_capacity(out.len()); + for (k, part) in out.split("##").enumerate() { + joined.push_str(if k == 0 { part.trim_end() } else { part.trim() }); + } + if out.contains("##") { joined } else { out } + } +} + +/// The arguments of a call whose `(` is the next non-blank token from `i`, split at top-level +/// commas, and the index after its `)`. `None` when no `(` follows or it never closes. +fn arguments(tokens: &[&str], mut i: usize) -> Option<(Vec, usize)> { + while tokens.get(i)?.trim().is_empty() { + i += 1; + } + if tokens[i] != "(" { + return None; + } + let (mut depth, mut args, mut from) = (0, Vec::new(), i + 1); + for (j, t) in tokens.iter().enumerate().skip(i + 1) { + match *t { + "(" => depth += 1, + ")" if depth == 0 => { + args.push(tokens[from..j].concat()); + return Some((args, j + 1)); + } + ")" => depth -= 1, + "," if depth == 0 => { + args.push(tokens[from..j].concat()); + from = j + 1; + } + _ => {} + } + } + None +} + +/// Preprocessing tokens: identifiers, numbers (with their suffixes and exponents), runs of +/// white space, `##`, and single characters. Concatenated they give back `text`. +pub(crate) fn lex(text: &str) -> Vec<&str> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + let start = i; + i += 1; + if is_ident_start(c) { + while i < bytes.len() && is_ident(bytes[i] as char) { + i += 1; + } + } else if c.is_ascii_digit() || (c == '.' && bytes.get(i).is_some_and(u8::is_ascii_digit)) { + while i < bytes.len() { + let d = bytes[i] as char; + let exp_sign = (d == '+' || d == '-') && matches!(bytes[i - 1], b'e' | b'E'); + if !(is_ident(d) || d == '.' || exp_sign) { + break; + } + i += 1; + } + } else if c.is_whitespace() { + while i < bytes.len() && (bytes[i] as char).is_whitespace() { + i += 1; + } + } else if c == '#' && bytes.get(i) == Some(&b'#') { + i += 1; + } else { + while !text.is_char_boundary(i) { + i += 1; + } + } + out.push(&text[start..i]); + } + out } pub(crate) fn is_ident_start(c: char) -> bool { @@ -128,4 +300,18 @@ mod tests { m.define("e5", "x"); assert_eq!(m.expand("1e5"), "1e5"); } + + #[test] + fn function_like_macros_take_arguments_and_paste() { + let mut m = Macros::default(); + m.define("N", "4"); + m.define_function("SQ", vec!["x".into()], "((x) * (x))"); + m.define_function("CAT", vec!["a".into(), "b".into()], "a ## b"); + m.define_function("PI", vec![], "3.14"); + assert_eq!(m.expand("SQ(N + 1)"), "((4 + 1) * (4 + 1))"); + assert_eq!(m.expand("SQ(min(N, 2))"), "((min(4, 2)) * (min(4, 2)))"); + assert_eq!(m.expand("CAT(N, 2) CAT(col, ortex4)"), "N2 colortex4"); + assert_eq!(m.expand("PI() SQ SQ(1"), "3.14 SQ SQ(1"); + assert_eq!(m.get("SQ"), None); + } } diff --git a/crates/horizon-shaders/src/properties.rs b/crates/horizon-shaders/src/properties.rs index fc3f00c816..b9d27f3e37 100644 --- a/crates/horizon-shaders/src/properties.rs +++ b/crates/horizon-shaders/src/properties.rs @@ -6,7 +6,8 @@ //! No other escape is read: values are names, numbers and expressions. A later duplicate key //! replaces the earlier one, with a note. -use crate::{Macros, eval_condition}; +use crate::Macros; +use crate::condition::{Conditionals, split_directive}; /// One `key=value`, with the line it starts on (from 1). #[derive(Clone, Debug, PartialEq)] @@ -30,29 +31,20 @@ pub struct Properties { pub notes: Vec, } -/// One branch level of `#if` ... `#endif`. -struct Branch { - /// This branch's lines are read. - on: bool, - /// A branch of this `#if` was already taken (the rest are skipped). - taken: bool, - /// The enclosing level is read. - outer: bool, - seen_else: bool, -} - impl Properties { /// Reads `text`; `#define`/`#undef` change the macros for the lines after them only. pub fn parse(text: &str, macros: &Macros) -> Self { let mut out = Self::default(); let mut macros = macros.clone(); - let mut branches: Vec = Vec::new(); + let mut conditionals = Conditionals::default(); let mut lines = text.lines().zip(1u32..); while let Some((raw, number)) = lines.next() { let line = raw.trim(); - let on = branches.last().is_none_or(|b| b.on); + let on = conditionals.active(); if let Some(directive) = line.strip_prefix('#') { - out.directive(directive.trim_start(), number, &mut branches, &mut macros); + if let Some((word, rest)) = split_directive(directive) { + out.directive(word, rest, number, &mut conditionals, &mut macros); + } continue; } // Continuation lines belong to their entry even inside a skipped branch. @@ -76,7 +68,7 @@ impl Properties { _ => out.note(number, "not a key=value line"), } } - if !branches.is_empty() { + if conditionals.open() { out.note(text.lines().count() as u32, "`#if` without `#endif`"); } out @@ -111,72 +103,30 @@ impl Properties { }); } - /// A line starting with `#`. It is a directive only when its first word is one; anything - /// else (`# text`, `#commented.key=1`, `#ifdefault`) is a comment. + /// A line starting with `#` whose first word may be a directive; any other word makes it a + /// comment. fn directive( &mut self, - line: &str, + word: &str, + rest: &str, number: u32, - branches: &mut Vec, + conditionals: &mut Conditionals, macros: &mut Macros, ) { - let word_len = line - .find(|c: char| !c.is_ascii_alphabetic()) - .unwrap_or(line.len()); - let (word, rest) = line.split_at(word_len); - if !(rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//")) { - return; - } - let rest = rest.split("//").next().unwrap_or("").trim(); - let on = branches.last().is_none_or(|b| b.on); - match word { - "if" | "ifdef" | "ifndef" => { - let value = on && self.condition(word, rest, number, macros); - branches.push(Branch { - on: value, - taken: value, - outer: on, - seen_else: false, - }); - } - "elif" | "else" => { - let Some(b) = branches.last_mut() else { - return self.note(number, &format!("`#{word}` without `#if`")); - }; - if b.seen_else { - return self.note(number, &format!("`#{word}` after `#else`")); + match conditionals.apply(word, rest, macros) { + Err(e) => self.note(number, &e), + Ok(true) => {} + Ok(false) if conditionals.active() => match word { + "define" => { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + macros.define(name, value.trim()); } - let take = b.outer && !b.taken; - b.seen_else = word == "else"; - b.on = take && (word == "else" || self.condition("if", rest, number, macros)); - b.taken |= b.on; - } - "endif" => { - if branches.pop().is_none() { - self.note(number, "`#endif` without `#if`"); - } - } - "define" if on => { - let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - macros.define(name, value.trim()); - } - "undef" if on => macros.undef(rest), - _ => {} + "undef" => macros.undef(rest), + _ => {} + }, + Ok(false) => {} } } - - fn condition(&mut self, word: &str, rest: &str, number: u32, macros: &Macros) -> bool { - let name = rest.split_whitespace().next().unwrap_or(""); - let result = match word { - "ifdef" => Ok(macros.is_defined(name) as i64), - "ifndef" => Ok(!macros.is_defined(name) as i64), - _ => eval_condition(rest, macros), - }; - result.unwrap_or_else(|e| { - self.note(number, &format!("`#{word} {rest}`: {e}; read as false")); - 0 - }) != 0 - } } /// An odd number of trailing backslashes: the last one joins the next line (`\\` is a literal). From f2399559979c1fd1a0e62c578a83ae4e5a29cd75 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:09:29 +0200 Subject: [PATCH 0008/2756] Shader packs, clean room 5: the GLSL preprocessor and #include A stage file goes through comment removal, #include, conditionals and macro expansion, and every output line remembers the pack file and line it came from, so later errors point at the pack's own code. - #include "x" from the including file's folder, "/x" from the shaders folder, expanded in live code only (an include inside a false #if is not read), ten levels at most, paths that leave the pack refused; a file is read once per run however often it is included. - #define, #undef, line continuations, __LINE__ and __VERSION__; #version and #extension kept apart for the translation's own header; the block comments of live code kept for the directives packs write in them (DRAWBUFFERS, buffer formats); #error and unknown directives diagnosed. Black box over the public test packs: every stage file of the base and dimension folders (126 to 219 per pack) preprocesses with no diagnostic. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/lib.rs | 3 + crates/horizon-shaders/src/pack.rs | 2 +- crates/horizon-shaders/src/preprocess.rs | 374 +++++++++++++++++++++ crates/horizon-shaders/tests/real_packs.rs | 36 +- 4 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 crates/horizon-shaders/src/preprocess.rs diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 05e51a53df..14f85b7584 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -6,6 +6,7 @@ //! - [`discover`]: the packs in a `shaderpacks` folder. //! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). +//! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). mod condition; @@ -13,6 +14,7 @@ mod discover; mod lang; mod macros; mod pack; +mod preprocess; mod properties; pub use condition::eval_condition; @@ -20,4 +22,5 @@ pub use discover::{Found, discover}; pub use lang::Lang; pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; +pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess}; pub use properties::{Entry, Note, Properties}; diff --git a/crates/horizon-shaders/src/pack.rs b/crates/horizon-shaders/src/pack.rs index 113db9ac92..ae22306184 100644 --- a/crates/horizon-shaders/src/pack.rs +++ b/crates/horizon-shaders/src/pack.rs @@ -170,7 +170,7 @@ impl Pack { } /// `a/./b/../c` → `a/c`; `None` for a path that climbs out of `shaders`. -fn normalize(path: &str) -> Option { +pub(crate) fn normalize(path: &str) -> Option { let mut parts: Vec<&str> = Vec::new(); for part in path.split(['/', '\\']) { match part { diff --git a/crates/horizon-shaders/src/preprocess.rs b/crates/horizon-shaders/src/preprocess.rs new file mode 100644 index 0000000000..4884f28f7c --- /dev/null +++ b/crates/horizon-shaders/src/preprocess.rs @@ -0,0 +1,374 @@ +//! The GLSL preprocessor a program's stage file goes through before translation (spec 1.4, +//! 1.5, 1.6): comments removed, `#include` expanded, conditionals evaluated, macros expanded. +//! Every output line remembers the pack file and line it came from, so errors found later point +//! at the pack's own code. +//! +//! `#include` is expanded where it stands in live code only: an include inside a false `#if` +//! is not read, so it can't fail (OQ-14). Ten levels deep at most, which also stops a file that +//! includes itself. `#version` and `#extension` are taken out of the code and kept apart: the +//! translation writes its own header. + +use std::collections::HashMap; + +use crate::Macros; +use crate::Pack; +use crate::condition::{Conditionals, split_directive}; +use crate::pack::normalize; + +const MAX_DEPTH: usize = 10; + +/// Where a line came from: an index into [`Preprocessed::files`] and a line number from 1. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Origin { + pub file: u16, + pub line: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Diagnostic { + pub origin: Origin, + pub message: String, +} + +#[derive(Debug, Default)] +pub struct Preprocessed { + /// The code, one line per entry of `lines`. + pub code: String, + pub lines: Vec, + /// The pack paths of the files read, the stage file first. + pub files: Vec, + /// `#version` of the stage file: the number and the profile word, if any. + pub version: Option<(u32, Option)>, + /// `#extension` lines of live code, as written after `#extension`. + pub extensions: Vec, + /// The text of every block comment in live code (`DRAWBUFFERS:`, buffer formats). + pub comments: Vec<(Origin, String)>, + pub diagnostics: Vec, +} + +impl Preprocessed { + /// `file:line` of an output line (from 0), for messages. + pub fn locate(&self, line: usize) -> String { + let o = self.lines.get(line).copied().unwrap_or_default(); + let file = self.files.get(o.file as usize).map_or("?", String::as_str); + format!("{file}:{}", o.line) + } +} + +/// Runs the preprocessor on the stage file `path` of `pack` with `macros` defined. +pub fn preprocess(pack: &Pack, path: &str, macros: &Macros) -> Preprocessed { + let mut run = Run { + pack, + macros: macros.clone(), + conditionals: Conditionals::default(), + out: Preprocessed::default(), + code: Vec::new(), + texts: HashMap::new(), + }; + run.file(path, 0, Origin::default()); + if run.conditionals.open() { + run.diagnose(Origin::default(), "`#if` without `#endif`"); + } + run.out.code = run.code.join("\n"); + run.out +} + +struct Run<'p> { + pack: &'p Pack, + macros: Macros, + conditionals: Conditionals, + out: Preprocessed, + code: Vec, + /// Files already read: a guarded header is included many times. + texts: HashMap>, +} + +impl Run<'_> { + fn file(&mut self, path: &str, depth: usize, from: Origin) { + let cached = self + .texts + .entry(path.to_owned()) + .or_insert_with(|| self.pack.read_text(path)); + let Some(text) = cached.clone() else { + return self.diagnose(from, &format!("can't find `{path}`")); + }; + let file = match self.out.files.iter().position(|f| f == path) { + Some(i) => i, + None => { + self.out.files.push(path.to_owned()); + self.out.files.len() - 1 + } + } as u16; + let (text, comments) = strip_comments(&text); + let lines: Vec<&str> = text.split('\n').map(|l| l.trim_end_matches('\r')).collect(); + let mut comments = comments.into_iter().peekable(); + let mut n = 0; + while n < lines.len() { + let first = n; + let mut line = lines[n].to_owned(); + while line.ends_with('\\') && n + 1 < lines.len() { + line.pop(); + n += 1; + line.push_str(lines[n]); + } + n += 1; + let origin = Origin { + file, + line: first as u32 + 1, + }; + self.line(&line, origin, path, depth); + for k in first + 1..n { + self.emit( + String::new(), + Origin { + file, + line: k as u32 + 1, + }, + ); + } + while let Some((_, comment)) = comments.next_if(|(at, _)| *at < n) { + if self.conditionals.active() { + self.out.comments.push((origin, comment)); + } + } + } + } + + fn line(&mut self, line: &str, origin: Origin, path: &str, depth: usize) { + let Some(after) = line.trim_start().strip_prefix('#') else { + let code = if self.conditionals.active() { + self.macros.define("__LINE__", origin.line.to_string()); + self.macros.expand(line) + } else { + String::new() + }; + return self.emit(code, origin); + }; + self.emit(String::new(), origin); + let Some((word, rest)) = split_directive(after) else { + return self.diagnose(origin, &format!("unknown directive `#{}`", after.trim())); + }; + match self.conditionals.apply(word, rest, &self.macros) { + Err(e) => return self.diagnose(origin, &e), + Ok(true) => return, + Ok(false) if !self.conditionals.active() => return, + Ok(false) => {} + } + match word { + "define" => self.define(rest, origin), + "undef" => self.macros.undef(rest), + "include" => { + let target = rest.trim_matches(|c| c == '"' || c == '<' || c == '>'); + match resolve(path, target) { + _ if depth + 1 >= MAX_DEPTH => self.diagnose( + origin, + &format!("`{target}`: includes nest more than {MAX_DEPTH} deep"), + ), + Some(file) => { + // The include line's own (empty) output line stays; the file follows. + self.file(&file, depth + 1, origin); + } + None => self.diagnose(origin, &format!("`{target}` is outside the pack")), + } + } + "version" if depth == 0 && self.out.version.is_none() => { + let mut words = rest.split_whitespace(); + let number = words.next().and_then(|v| v.parse().ok()).unwrap_or(110); + self.out.version = Some((number, words.next().map(str::to_owned))); + self.macros.define("__VERSION__", number.to_string()); + } + "extension" => self.out.extensions.push(rest.to_owned()), + "error" => self.diagnose(origin, &format!("#error {rest}")), + "version" | "pragma" | "line" | "" => {} + _ => self.diagnose(origin, &format!("unknown directive `#{word}`")), + } + } + + /// `#define NAME body` or `#define NAME(a, b) body` (no space before the `(`). + fn define(&mut self, rest: &str, origin: Origin) { + let len = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let (name, tail) = rest.split_at(len); + if name.is_empty() { + return self.diagnose(origin, "`#define` without a name"); + } + if let Some(params) = tail.strip_prefix('(') { + let Some((params, body)) = params.split_once(')') else { + return self.diagnose(origin, &format!("`#define {name}(` without `)`")); + }; + let params = params + .split(',') + .map(|p| p.trim().to_owned()) + .filter(|p| !p.is_empty()) + .collect(); + self.macros.define_function(name, params, body.trim()); + } else { + self.macros.define(name, tail.trim()); + } + } + + fn emit(&mut self, code: String, origin: Origin) { + self.code.push(code); + self.out.lines.push(origin); + } + + fn diagnose(&mut self, origin: Origin, message: &str) { + self.out.diagnostics.push(Diagnostic { + origin, + message: message.to_owned(), + }); + } +} + +/// An `#include` path: from the `shaders` folder with a leading `/`, else from the including +/// file's folder (spec 1.4). `None` when it climbs out of the pack. +fn resolve(from_file: &str, target: &str) -> Option { + match target.strip_prefix('/') { + Some(absolute) => normalize(absolute), + None => { + let dir = from_file.rsplit_once('/').map_or("", |(dir, _)| dir); + normalize(&format!("{dir}/{target}")) + } + } +} + +/// `text` with comments replaced by spaces (new lines kept, so line numbers hold), and the body of +/// each block comment with the index of the line it starts on. +fn strip_comments(text: &str) -> (String, Vec<(usize, String)>) { + let mut out = String::with_capacity(text.len()); + let mut comments = Vec::new(); + let (mut rest, mut line) = (text, 0); + while let Some(at) = rest.find('/') { + let (before, from) = rest.split_at(at); + out.push_str(before); + line += before.matches('\n').count(); + if let Some(body) = from.strip_prefix("//") { + let end = body.find('\n').unwrap_or(body.len()); + rest = &body[end..]; + } else if let Some(body) = from.strip_prefix("/*") { + let end = body.find("*/").unwrap_or(body.len()); + let comment = &body[..end]; + comments.push((line, comment.to_owned())); + let newlines = comment.matches('\n').count(); + line += newlines; + out.push(' '); + out.extend(std::iter::repeat_n('\n', newlines)); + rest = body.get(end + 2..).unwrap_or(""); + } else { + out.push('/'); + rest = &from[1..]; + } + } + out.push_str(rest); + (out, comments) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + + fn run(files: &[(&str, &str)]) -> Preprocessed { + let files: Vec<(String, &str)> = files + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + preprocess(&pack, "program/final.fsh", &Macros::standard()) + } + + fn code_lines(p: &Preprocessed) -> Vec<(&str, String)> { + p.code + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(i, l)| (l.trim(), p.locate(i))) + .collect() + } + + #[test] + fn includes_resolve_and_lines_map_back() { + let p = run(&[ + ( + "program/final.fsh", + "#version 330 compatibility\n#include \"/lib/a.glsl\"\nvoid main() {}\n", + ), + ( + "lib/a.glsl", + "#ifndef A\n#define A\n#include \"b.glsl\"\nfloat a;\n#endif\n", + ), + ("lib/b.glsl", "// b\nfloat b;\n#include \"a.glsl\"\n"), + ]); + assert_eq!(p.version, Some((330, Some("compatibility".into())))); + assert_eq!( + code_lines(&p), + [ + ("float b;", "lib/b.glsl:2".into()), + ("float a;", "lib/a.glsl:4".into()), + ("void main() {}", "program/final.fsh:3".into()), + ] + ); + assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics); + } + + #[test] + fn missing_and_runaway_includes_are_diagnosed_where_they_stand() { + let p = run(&[( + "program/final.fsh", + "#ifdef NOPE\n#include \"gone.glsl\"\n#endif\n#include \"gone.glsl\"\n#include \"final.fsh\"\n", + )]); + let messages: Vec = p + .diagnostics + .iter() + .map(|d| format!("{}: {}", d.origin.line, d.message)) + .collect(); + assert_eq!(messages[0], "4: can't find `program/gone.glsl`"); + assert!( + messages + .last() + .unwrap() + .ends_with("includes nest more than 10 deep") + ); + assert!( + run(&[("program/final.fsh", "#include \"../../x\"")]).diagnostics[0] + .message + .contains("outside") + ); + } + + #[test] + fn macros_expand_like_the_glsl_preprocessor() { + let p = run(&[( + "program/final.fsh", + "#define SQ(x) ((x) * (x))\n#define CAT(a, b) a ## b\n#define N 4\n#define \\\n LONG 1\n\ + float v = SQ(N + 1) + CAT(N, 2) + LONG;\nint l = __LINE__;\n#if MC_VERSION > 12100 && defined MC_RENDER_STAGE_SKY\nint ok;\n#else\nint bad;\n#endif\n", + )]); + let lines: Vec<&str> = code_lines(&p).into_iter().map(|(l, _)| l).collect(); + assert_eq!( + lines, + [ + "float v = ((4 + 1) * (4 + 1)) + N2 + 1;", + "int l = 7;", + "int ok;" + ] + ); + assert_eq!(p.lines.len(), p.code.split('\n').count()); + } + + #[test] + fn comments_extensions_and_errors() { + let p = run(&[( + "program/final.fsh", + "#version 120\n#extension GL_ARB_shader_texture_lod : enable\n/* DRAWBUFFERS:04 */\n#if 0\n/* RENDERTARGETS: 1 */\n#error dead\n#endif\n\ + /* multi\nline */ float x; // gone\n#error live\n#bogus\n", + )]); + assert_eq!(p.extensions, ["GL_ARB_shader_texture_lod : enable"]); + let comments: Vec<&str> = p.comments.iter().map(|(_, c)| c.trim()).collect(); + assert_eq!(comments, ["DRAWBUFFERS:04", "multi\nline"]); + assert_eq!(code_lines(&p), [("float x;", "program/final.fsh:9".into())]); + let messages: Vec<&str> = p.diagnostics.iter().map(|d| d.message.as_str()).collect(); + assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]); + } +} diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index e0ad38c250..c252df6e33 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::time::Instant; -use horizon_shaders::{Lang, Macros, Pack, Properties, discover}; +use horizon_shaders::{Lang, Macros, Pack, Properties, discover, preprocess}; #[test] #[ignore = "needs HORIZON_TEST_SHADER_PACKS"] @@ -52,6 +52,40 @@ fn real_packs_read() { for n in &shaders.notes { println!(" shaders.properties:{}: {}", n.line, n.message); } + // Every stage file of the base and dimension folders through the preprocessor. + let start = Instant::now(); + let (mut stages, mut lines, mut diagnostics) = (0, 0, Vec::new()); + for dir in ["", "world0", "world-1", "world1"] { + for file in pack.list(dir) { + if [".vsh", ".fsh", ".gsh", ".csh"] + .iter() + .any(|e| file.ends_with(e)) + { + let path = if dir.is_empty() { + file + } else { + format!("{dir}/{file}") + }; + let p = preprocess(&pack, &path, ¯os); + stages += 1; + lines += p.lines.len(); + diagnostics.extend(p.diagnostics.iter().map(|d| { + let file = &p.files[d.origin.file as usize]; + format!("{file}:{}: {}", d.origin.line, d.message) + })); + } + } + } + println!( + " {stages} stage files preprocessed, {lines} lines out, {} diagnostics, {:.1} ms", + diagnostics.len(), + start.elapsed().as_secs_f64() * 1000.0 + ); + diagnostics.sort(); + diagnostics.dedup(); + for d in diagnostics.iter().take(8) { + println!(" {d}"); + } assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } From 7c1dd4981062ce9e0cb0db0b7d919109243b2470 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:13:00 +0200 Subject: [PATCH 0009/2756] Renderer: leave out solid quads that face away from the eye Meshing sorts each section's and distant tile's solid quads into seven buckets: one per axis direction (all four corners in one axis plane, both triangles wound the same way) and one for everything else, and keeps each axis bucket's extreme plane. A quad facing +X at x = p is a back face from an eye at x <= p, which the solid pipeline culls anyway, so the frame draws only the buckets that can face the eye, merged into runs of adjacent buckets: one to three indirect draws per section on the existing page-run, occlusion-culling and flat paths. The eye comes from the frame's view-projection, so view bobbing is exact. The picture does not change; `horizon-ctl section-sides on|off` switches it for A/B checks. Written from scratch from the general back-face technique; no other renderer's code or Horizon's removed code read. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/control.rs | 10 + .../horizon-client/src/game/render_control.rs | 1 + crates/horizon-client/src/gfx.rs | 94 ++++- crates/horizon-client/src/lod/mesh.rs | 6 +- crates/horizon-client/src/mesher.rs | 14 + crates/horizon-client/src/mesher/sides.rs | 346 ++++++++++++++++++ 6 files changed, 455 insertions(+), 16 deletions(-) create mode 100644 crates/horizon-client/src/mesher/sides.rs diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 205a660745..6fbd984fdd 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -226,6 +226,16 @@ impl Game<'_> { } Ok(Some(json!({ "ok": true, "cull": r.section_cull() }))) } + "section-sides" => { + let r = self.renderer.as_mut().ok_or("no renderer")?; + match words.get(1).copied() { + None => {} + Some("on") => r.set_section_sides(true), + Some("off") => r.set_section_sides(false), + Some(other) => return Err(format!("unknown section sides mode {other}").into()), + } + Ok(Some(json!({ "ok": true, "sides": r.section_sides() }))) + } "sculk" => Ok(Some(self.sculk_command(&words)?)), "status" => Ok(Some(self.control_status())), "pad" => Ok(Some(self.pad_command(&words)?)), diff --git a/crates/horizon-client/src/game/render_control.rs b/crates/horizon-client/src/game/render_control.rs index 31a7a33a07..0a2f9b7152 100644 --- a/crates/horizon-client/src/game/render_control.rs +++ b/crates/horizon-client/src/game/render_control.rs @@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[ "signedit X Y Z [front|back] open the sign editor for the sign at X Y Z", "section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode", "section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode", + "section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode", ]; impl Game<'_> { diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 9ed0bd8a54..e4c3367f9a 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -6,7 +6,7 @@ use crate::{ environment::Sky, graphics::GraphicsApi, hud::{HudBatch, HudRenderer}, - mesher::{SectionMesh, TerrainVertex, Vertex}, + mesher::{SectionMesh, Sides, TerrainVertex, Vertex}, sprites::SpriteAnimation, }; use glam::{DVec3, Mat4, Vec3, Vec4}; @@ -156,6 +156,8 @@ struct SectionGpu { page: u32, start: u64, solid: u32, + /// Where the solid quads' buckets by facing side end (`mesher::Sides`). + sides: Sides, translucent: u32, sort: translucency::SortState, sorted_indices: Option<(u32, u64)>, @@ -323,6 +325,8 @@ pub struct Renderer { /// counts reach a multi-draw's entries (Vulkan, Metal). cull: Option, cull_on: bool, + /// Solid quads facing away from the eye are left out (`mesher::Sides`). + sides_on: bool, sky: wgpu::RenderPipeline, dark_disc: wgpu::RenderPipeline, lines: wgpu::RenderPipeline, @@ -2202,6 +2206,7 @@ impl Renderer { translucent, flat_on: flat.is_some(), cull_on: cull.is_some(), + sides_on: true, cull, flat, sky, @@ -2451,6 +2456,14 @@ impl Renderer { self.flat_on = on; Ok(()) } + /// Whether solid quads facing away from the eye are left out. + pub fn section_sides(&self) -> bool { + self.sides_on + } + /// Switches leaving out solid quads facing away, for A/B checks. + pub fn set_section_sides(&mut self, on: bool) { + self.sides_on = on; + } /// Whether solid sections are occlusion culled (`cull`). pub fn section_cull(&self) -> bool { self.cull_on @@ -2573,6 +2586,11 @@ impl Renderer { page, start, solid, + sides: if mesh.sides.quads() == solid { + mesh.sides + } else { + Sides::unsorted(solid) + }, translucent, sort: translucency::SortState::new( mesh.translucent @@ -3039,6 +3057,9 @@ impl Renderer { }; let camera = frame.camera; let camera_section = camera_section(camera); + // Solid quads facing away from the eye are left out: the bob moves + // the eye off `camera`, so it is found from the projection. + let eye = projection_center(view_proj).filter(|_| self.sides_on); let culling = !frame.title && frame.cave_culling; if culling { crate::spans::time("occlusion", || { @@ -3127,11 +3148,13 @@ impl Renderer { }; let start = draws.len(); let instances = u32::from(first_phase); - let indices = s.solid * 6; - if indices > 0 { - draws.push([indices, instances, 0, s.start as u32, i as u32]); + let mut indices = 0; + facing_runs(&s.sides, eye, v.origin, |first, quads| { + indices += quads * 6; + let base = s.start as u32 + first * 4; + draws.push([quads * 6, instances, 0, base, i as u32]); pages.push((s.page, None)); - } + }); let runs = if first_phase { &mut self.solid_runs } else { @@ -3168,16 +3191,12 @@ impl Renderer { for (i, v) in visible_lods.iter().enumerate() { let s = &self.lods[&v.key].mesh; let start = self.draws.len(); - if s.solid > 0 { - self.draws.push([ - s.solid * 6, - 1, - 0, - s.start as u32, - (visible.len() + i) as u32, - ]); + let instance = (visible.len() + i) as u32; + facing_runs(&s.sides, eye, v.origin, |first, quads| { + let base = s.start as u32 + first * 4; + self.draws.push([quads * 6, 1, 0, base, instance]); self.draw_pages.push((s.page, None)); - } + }); self.solid_runs .push(v.distance, s.page, start, self.draws.len() - start, [0; 3]); } @@ -4196,6 +4215,34 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 { Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05) } +/// Where a perspective `view_proj` sees from, in its own (camera-relative) +/// space: the point whose clip x, y and w are all 0. `None` for a +/// projection without one (orthographic). +fn projection_center(view_proj: Mat4) -> Option { + let m = view_proj.as_dmat4(); + let rows = [m.row(0), m.row(1), m.row(3)]; + let a = glam::DMat3::from_cols( + DVec3::new(rows[0].x, rows[1].x, rows[2].x), + DVec3::new(rows[0].y, rows[1].y, rows[2].y), + DVec3::new(rows[0].z, rows[1].z, rows[2].z), + ); + let det = a.determinant(); + if !det.is_normal() { + return None; + } + let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w); + Some((a.inverse() * -b).as_vec3()) +} +/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can +/// face `eye` (`projection_center`), the mesh at the camera-relative +/// `origin`; all of them when there is no eye. +fn facing_runs(sides: &Sides, eye: Option, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) { + match eye { + Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw), + None if sides.quads() > 0 => draw(0, sides.quads()), + None => {} + } +} fn frustum(m: Mat4) -> [Vec4; 6] { let r = |i| m.row(i); let planes = [ @@ -4423,6 +4470,25 @@ mod tests { assert_eq!(super::outline_width(3840), 5.0); } #[test] + fn the_projection_center_follows_the_view_bob() { + use glam::{Mat4, Vec3}; + let projection = super::world_projection(70.0, 16.0 / 9.0, 512.0); + let view = crate::camera::view_matrix(30.0, 20.0); + let eye = super::projection_center(projection * view).unwrap(); + assert!(eye.length() < 1e-5, "{eye}"); + // A pose translated by the bob: the eye is where the pose's inverse + // takes the view's origin. + let bob = Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.03, -0.08, 0.0)); + let expected = (bob * view).inverse().transform_point3(Vec3::ZERO); + let eye = super::projection_center(projection * bob * view).unwrap(); + assert!(eye.distance(expected) < 1e-5, "{eye} {expected}"); + assert!(expected.length() > 0.05); + assert!( + super::projection_center(Mat4::orthographic_rh(-1.0, 1.0, -1.0, 1.0, 0.0, 1.0)) + .is_none() + ); + } + #[test] fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() { let validated = |backends| { super::instance_descriptor(backends) diff --git a/crates/horizon-client/src/lod/mesh.rs b/crates/horizon-client/src/lod/mesh.rs index 852f45e5b4..bdd77fbea9 100644 --- a/crates/horizon-client/src/lod/mesh.rs +++ b/crates/horizon-client/src/lod/mesh.rs @@ -67,7 +67,7 @@ impl Mesher { neighbors: &[Vec; 4], finer: &[TileKey], ) -> SectionMesh { - mesh_columns_with_edges( + let mut mesh = mesh_columns_with_edges( key, columns, covered, @@ -75,7 +75,9 @@ impl Mesher { neighbors, finer, |span, direction| self.material(span, direction, context), - ) + ); + mesh.sort_solid(); + mesh } fn material(&mut self, span: &Span, direction: Direction, context: &WorkerContext) -> Material { let styles = *self diff --git a/crates/horizon-client/src/mesher.rs b/crates/horizon-client/src/mesher.rs index ec6f2ae739..d1b27bec97 100644 --- a/crates/horizon-client/src/mesher.rs +++ b/crates/horizon-client/src/mesher.rs @@ -5,6 +5,7 @@ //! follow `FluidRenderer` (see `fluid`). mod fluid; mod lighting; +mod sides; use crate::{ assets::{Assets, RenderAssets}, biome::Biomes, @@ -20,6 +21,7 @@ use horizon_world::{ fluid::{FluidKind, FluidState}, registry::BlockStateId, }; +pub use sides::Sides; use std::sync::Arc; /// An entity, item and effect vertex (the entity pipelines), with a face @@ -114,6 +116,9 @@ mod vertex_tests { } pub struct SectionMesh { pub solid: Vec, + /// The buckets `solid` is sorted in by the side its quads face + /// (`sort_solid`; empty until then). + pub sides: Sides, pub translucent: Vec, /// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction /// order) are connected through non-opaque blocks. @@ -127,12 +132,20 @@ impl Default for SectionMesh { fn default() -> Self { Self { solid: Vec::new(), + sides: Sides::default(), translucent: Vec::new(), visibility: ALL_VISIBLE, block_entities: Vec::new(), } } } +impl SectionMesh { + /// Sorts the solid quads by the side they face, for the renderer to + /// leave out those facing away from the eye (`sides`). + pub fn sort_solid(&mut self) { + self.sides = sides::sort(&mut self.solid); + } +} pub const ALL_VISIBLE: u64 = (1 << 36) - 1; /// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches. fn visibility(pad: &Padded, info: &[Info]) -> u64 { @@ -798,6 +811,7 @@ impl Mesher<'_> { } } } + out.sort_solid(); out } /// `BlockColors` tint source `index`, or `None` for an untinted (-1) layer. diff --git a/crates/horizon-client/src/mesher/sides.rs b/crates/horizon-client/src/mesher/sides.rs new file mode 100644 index 0000000000..797d60127b --- /dev/null +++ b/crates/horizon-client/src/mesher/sides.rs @@ -0,0 +1,346 @@ +//! A mesh's solid quads sorted by the side they face, so that the renderer +//! can leave out the quads turned away from the eye before the GPU shades +//! their vertices. +//! +//! A quad in the plane `x = p` whose corners go counter-clockwise seen from +//! +X faces +X. From an eye at `x < p` it is seen from behind, and at +//! `x = p` edge on; the solid pipeline culls back faces, so it draws nothing +//! from there. A bucket of such quads draws nothing at all while the eye is +//! at or below the lowest of their planes, and likewise for the other five +//! axis directions. The seventh bucket holds every other quad (sloped, +//! rotated, degenerate, or whose two triangles wind opposite ways): it is +//! drawn from everywhere. +//! +//! The buckets lie one after another in the mesh, in an order chosen so that +//! the buckets an eye can see are usually one or two runs of quads: one draw +//! each. Inside a bucket quads keep their model order, so coplanar quads +//! facing one way (an overlay on its base) still draw in that order. +use super::TerrainVertex; + +/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from +/// above (+Y, the commonest case), each pair of horizontal sides with a +/// common corner is then one run, or two. +const BUCKETS: [Bucket; 7] = [ + Bucket::Side(0, true), + Bucket::Side(2, true), + Bucket::Other, + Bucket::Side(1, true), + Bucket::Side(2, false), + Bucket::Side(0, false), + Bucket::Side(1, false), +]; +const OTHER: usize = 2; +/// How far, in blocks, the eye must be behind a bucket's planes before the +/// bucket is left out. The renderer computes both the eye and the section +/// origins in `f32`, with errors far below this. +const MARGIN: f32 = 1.0 / 16.0; + +#[derive(Clone, Copy)] +enum Bucket { + /// Quads in a plane across `axis` facing its positive (`true`) or + /// negative direction. + Side(usize, bool), + Other, +} + +/// Where each bucket of a mesh's solid quads ends, and the planes that tell +/// whether it can face the eye. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Sides { + /// The solid quads up to the end of each bucket, in `BUCKETS` order. + ends: [u32; 7], + /// Per bucket facing a positive direction, its lowest plane; facing a + /// negative one, its highest (unused for the other bucket). + planes: [f32; 7], +} + +impl Sides { + /// `quads` solid quads in one bucket drawn from everywhere: for meshes + /// that were not sorted. + pub fn unsorted(quads: u32) -> Self { + let mut ends = [0; 7]; + ends[OTHER..].fill(quads); + Self { + ends, + planes: [0.0; 7], + } + } + + /// The number of solid quads the buckets hold. + pub fn quads(&self) -> u32 { + self.ends[6] + } + + /// Calls `draw(first, count)` for each run of quads an eye at `eye` + /// (relative to the mesh's origin) can see the front of, nearest the + /// start first. Empty buckets do not break a run. + pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) { + let mut start = 0; + let mut run: Option = None; + for (i, bucket) in BUCKETS.iter().enumerate() { + let end = self.ends[i]; + if end == start { + continue; + } + let seen = match *bucket { + Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN, + Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN, + Bucket::Other => true, + }; + match (seen, run) { + (true, None) => run = Some(start), + (false, Some(first)) => { + draw(first, start - first); + run = None; + } + _ => {} + } + start = end; + } + if let Some(first) = run { + draw(first, start - first); + } + } +} + +/// The bucket of a quad: the axis plane it lies in and the side its +/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face. +fn bucket(quad: &[TerrainVertex; 4]) -> usize { + let p = quad.map(|v| v.pos); + for axis in 0..3 { + if p.iter().any(|v| v[axis] != p[0][axis]) { + continue; + } + // The normal's component along `axis`, twice each triangle's area. + let (u, w) = ((axis + 1) % 3, (axis + 2) % 3); + let area = |a: usize, b: usize, c: usize| { + (p[b][u] - p[a][u]) * (p[c][w] - p[a][w]) - (p[b][w] - p[a][w]) * (p[c][u] - p[a][u]) + }; + let (first, second) = (area(0, 1, 2), area(0, 2, 3)); + let positive = first >= 0.0 && second >= 0.0; + let negative = first <= 0.0 && second <= 0.0; + if positive == negative { + // Opposite windings, or no area at all. + return OTHER; + } + return BUCKETS + .iter() + .position(|b| matches!(*b, Bucket::Side(a, s) if a == axis && s == positive)) + .expect("every side has a bucket"); + } + OTHER +} + +/// Reorders `solid`'s quads by bucket, keeping their order inside each. +pub fn sort(solid: &mut Vec) -> Sides { + let (quads, rest) = solid.as_chunks::<4>(); + debug_assert!(rest.is_empty(), "solid vertices come in quads"); + let buckets: Vec = quads.iter().map(|q| bucket(q) as u8).collect(); + let mut counts = [0u32; 7]; + let mut planes = [0f32; 7]; + for (quad, &b) in quads.iter().zip(&buckets) { + let b = usize::from(b); + if let Bucket::Side(axis, positive) = BUCKETS[b] { + let p = quad[0].pos[axis]; + planes[b] = match (counts[b], positive) { + (0, _) => p, + (_, true) => planes[b].min(p), + (_, false) => planes[b].max(p), + }; + } + counts[b] += 1; + } + let mut ends = [0u32; 7]; + let mut total = 0; + for (end, count) in ends.iter_mut().zip(counts) { + total += count; + *end = total; + } + if counts.iter().filter(|&&c| c > 0).count() > 1 { + let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]); + let mut sorted = vec![quads[0]; quads.len()]; + for (quad, &b) in quads.iter().zip(&buckets) { + let slot = &mut next[usize::from(b)]; + sorted[*slot as usize] = *quad; + *slot += 1; + } + *solid = sorted.into_flattened(); + } + Sides { ends, planes } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] { + corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0)) + } + + /// A unit block's six faces at `origin`, wound counter-clockwise seen + /// from outside, as block models are. + fn cube(origin: [f32; 3], layer: u16) -> Vec { + let [x, y, z] = origin; + let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0); + [ + [[a, y, z], [a, b, z], [a, b, c], [a, y, c]], // +X + [[x, y, z], [x, y, c], [x, b, c], [x, b, z]], // -X + [[x, b, z], [x, b, c], [a, b, c], [a, b, z]], // +Y + [[x, y, z], [a, y, z], [a, y, c], [x, y, c]], // -Y + [[x, y, c], [a, y, c], [a, b, c], [x, b, c]], // +Z + [[x, y, z], [x, b, z], [a, b, z], [a, y, z]], // -Z + ] + .into_iter() + .flat_map(|q| quad(q, layer)) + .collect() + } + + fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + sides.visible(eye, |first, count| out.push((first, count))); + out + } + + /// The quads of `solid` whose triangles face `eye`, by their layer. + fn front_facing(solid: &[TerrainVertex], eye: [f32; 3]) -> Vec { + let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + solid + .as_chunks::<4>() + .0 + .iter() + .filter(|q| { + [[0, 1, 2], [0, 2, 3]].iter().any(|t| { + let (e1, e2) = (sub(q[t[1]].pos, q[t[0]].pos), sub(q[t[2]].pos, q[t[0]].pos)); + let n = [ + e1[1] * e2[2] - e1[2] * e2[1], + e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0], + ]; + let to_eye = sub(eye, q[t[0]].pos); + n[0] * to_eye[0] + n[1] * to_eye[1] + n[2] * to_eye[2] > 0.0 + }) + }) + .map(|q| q[0].layer) + .collect() + } + + #[test] + fn cube_faces_go_to_their_buckets_in_order() { + let mut solid = cube([3.0, 4.0, 5.0], 0); + for (i, v) in solid.iter_mut().enumerate() { + v.layer = (i / 4) as u16; + } + let sides = sort(&mut solid); + assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]); + let layers: Vec = solid.iter().step_by(4).map(|v| v.layer).collect(); + // +X, +Z, +Y, -Z, -X, -Y. + assert_eq!(layers, [0, 4, 2, 5, 1, 3]); + assert_eq!(sides.planes[0], 4.0); // +X at x = 4 + assert_eq!(sides.planes[5], 3.0); // -X at x = 3 + assert_eq!(sides.planes[3], 5.0); // +Y at y = 5 + } + + #[test] + fn the_drawn_runs_hold_every_quad_that_faces_the_eye() { + // Blocks across a section, with a sloped quad and a bowtie. + let mut solid = Vec::new(); + let mut layer = 0; + for origin in [ + [0.0, 0.0, 0.0], + [7.0, 3.0, 12.0], + [15.0, 15.0, 15.0], + [2.0, 9.0, 4.0], + ] { + for q in cube(origin, 0).chunks(4) { + solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v })); + layer += 1; + } + } + let slope = [ + [1.0, 1.0, 1.0], + [2.0, 1.0, 1.0], + [2.0, 2.0, 2.0], + [1.0, 2.0, 2.0], + ]; + let bowtie = [ + [5.0, 1.0, 1.0], + [5.0, 2.0, 2.0], + [5.0, 1.0, 2.0], + [5.0, 2.0, 1.0], + ]; + for corners in [slope, bowtie] { + solid.extend(quad(corners, layer)); + layer += 1; + } + let sides = sort(&mut solid); + assert_eq!(sides.quads(), u32::from(layer)); + let eyes = [-20.0f32, -0.5, 0.0, 3.5, 8.0, 15.99, 16.0, 30.0]; + for &x in &eyes { + for &y in &eyes { + for &z in &eyes { + let eye = [x, y, z]; + let mut drawn = Vec::new(); + for (first, count) in runs(&sides, eye) { + drawn.extend( + solid[first as usize * 4..(first + count) as usize * 4] + .iter() + .step_by(4) + .map(|v| v.layer), + ); + } + for layer in front_facing(&solid, eye) { + assert!(drawn.contains(&layer), "{eye:?} misses quad {layer}"); + } + } + } + } + } + + #[test] + fn runs_merge_across_hidden_and_empty_buckets() { + let mut solid = cube([0.0; 3], 0); + let sides = sort(&mut solid); + // From above, +X and +Z: +X, +Z, (no other), +Y is one run. + assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]); + // From below, -X and -Z: -Z, -X, -Y. + assert_eq!(runs(&sides, [-8.0, -8.0, -8.0]), [(3, 3)]); + // From above, -X and +Z: +Z, +Y, then -X. + assert_eq!(runs(&sides, [-8.0, 8.0, 8.0]), [(1, 2), (4, 1)]); + // Inside the block every face is seen from behind. + assert!(runs(&sides, [0.5; 3]).is_empty()); + // Level with its top, the eye may see it: the margin keeps it. + assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]); + } + + #[test] + fn unsorted_meshes_draw_everything() { + let sides = Sides::unsorted(9); + assert_eq!(sides.quads(), 9); + assert_eq!(runs(&sides, [-100.0; 3]), [(0, 9)]); + assert!(runs(&Sides::unsorted(0), [0.0; 3]).is_empty()); + } + + #[test] + fn one_bucket_keeps_the_mesh_as_it_is() { + let mut solid: Vec<_> = (0..3) + .flat_map(|i| { + quad( + [ + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 1.0, 1.0], + [1.0, 0.0, 1.0], + ], + i, + ) + }) + .collect(); + let before = solid.clone(); + let sides = sort(&mut solid); + assert_eq!(sides.ends, [3; 7]); + assert_eq!( + solid.iter().map(|v| v.layer).collect::>(), + before.iter().map(|v| v.layer).collect::>() + ); + } +} From 787c3a2f19e52cdbd63069a29a47e1a6507b6d77 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:20:34 +0200 Subject: [PATCH 0010/2756] Website: /mods, Horizon's registry of mods, modpacks and resource packs The list (search, a filter per kind) and a page per project: what a mod may do in the game's own words, Signed or Unsigned, how to install it from the launcher, the CLI or by hand, its file, SHA-256 and versions, and links to its source, its registry entry and the report form. Baked from the published index at build time (HORIZON_MOD_INDEX for a test index) and refreshed by the page; Caddy serves /mods/ from its baked page, else the list page, which shows a project published since. Co-Authored-By: Claude Opus 5.5 --- website/Caddyfile | 12 ++++- website/README.md | 7 ++- website/build.mjs | 71 +++++++++++++++++++++--- website/src/mods-page.js | 66 +++++++++++++++++++++++ website/src/mods.html | 37 +++++++++++++ website/src/mods.js | 110 ++++++++++++++++++++++++++++++++++++++ website/src/mods.test.mjs | 42 +++++++++++++++ website/src/style.css | 48 +++++++++++++++++ 8 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 website/src/mods-page.js create mode 100644 website/src/mods.html create mode 100644 website/src/mods.js create mode 100644 website/src/mods.test.mjs diff --git a/website/Caddyfile b/website/Caddyfile index 1571566ade..60554fd207 100644 --- a/website/Caddyfile +++ b/website/Caddyfile @@ -20,7 +20,7 @@ Referrer-Policy strict-origin-when-cross-origin X-Frame-Options DENY Permissions-Policy "interest-cohort=(), browsing-topics=(), camera=(), microphone=(), geolocation=()" - Content-Security-Policy "default-src 'self'; script-src 'self' {$CSP_SCRIPT}; style-src 'self' {$CSP_STYLE}; img-src 'self' data: https://avatars.githubusercontent.com; font-src 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com https://telemetry.playhorizon.dev; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + Content-Security-Policy "default-src 'self'; script-src 'self' {$CSP_SCRIPT}; style-src 'self' {$CSP_STYLE}; img-src 'self' data: https://avatars.githubusercontent.com https://ludovic111.github.io; font-src 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com https://telemetry.playhorizon.dev https://ludovic111.github.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" -Server } @assets path /assets/* @@ -35,7 +35,15 @@ rewrite /benchmarks /benchmarks.html @doc path_regexp doc ^/docs/([a-z-]+)$ rewrite @doc /docs/{re.doc.1}.html - @pages path / /index.html /404.html /privacy.html /releases /releases.html /benchmarks /benchmarks.html /benchmarks.json /docs /docs/* /sitemap.xml + # The registry: /mods, and /mods/ baked at build time, else the list page, whose script shows a project + # published since. + rewrite /mods /mods.html + @mod { + path_regexp ^/mods/[a-z0-9_]+$ + file {path}.html /mods.html + } + rewrite @mod {file_match.relative} + @pages path / /index.html /404.html /privacy.html /releases /releases.html /benchmarks /benchmarks.html /benchmarks.json /docs /docs/* /mods /mods.html /mods/* /sitemap.xml header @pages Cache-Control "no-cache" @legal path /LEGAL.md /fonts/*.txt header @legal Content-Type "text/plain; charset=utf-8" diff --git a/website/README.md b/website/README.md index 8ac6e39101..52e26ec611 100644 --- a/website/README.md +++ b/website/README.md @@ -22,12 +22,14 @@ src/404.html the not-found page src/privacy.html what the launcher can send when players allow it, retention, deletion (/privacy) src/releases.html every release, newest first, with what it adds and fixes, its notes and downloads (/releases) src/benchmarks.html Horizon against vanilla 26.3 on our machines, the hardware picker, players' results (/benchmarks) +src/mods.html the registry's list (/mods) and a page per project (/mods/), one template src/docs.html the template of every documentation page (/docs/...), with its left navigation src/tokens.css the design tokens, generated from crates/horizon-theme (do not edit) src/style.css every style, on top of the tokens src/main.js the home page: download buttons, recent releases, links, contributors, nav, tabs, lightbox, motion src/chrome.js the light/dark switch, the phone menu and GitHub fetching, shared by the pages src/releases-page.js the Releases page: the channel filter and the refresh from GitHub +src/mods.js the registry's markup (list, project page), shared by build and page; mods-page.js: the filter, the search, the refresh src/benchmarks-page.js the Benchmarks page: players' results from the telemetry server, the picker src/bench.js the Benchmarks tables, shared by build and page src/hardware.js the spec table estimates use (GPU TFLOPS and bandwidth, CPU single-thread score) @@ -48,14 +50,15 @@ shots/ (gitignored) full-page screenshots at 360–1920 px, a 1250× ## What comes from where -- **Built in** (`build.mjs`): the parity table, its score, harness count and audit date, +- **Built in** (`build.mjs`): Horizon's registry (`/mods`, one page per project) from the index ludovic111/horizon-mods + publishes (`HORIZON_MOD_INDEX=` builds from a test index; docs/api/mod-index.md); the parity table, its score, harness count and audit date, all from `README.md`; the documentation pages (`/docs`) from `website/docs/`; the Discord and Sponsor links from `launcher/links.json`; the latest releases and the contributors (`/repos/ludovic111/horizon/contributors`, bots left out, the owner first), when GitHub answers; without an answer the build still succeeds and the Contributors block and its menu entries stay hidden until the page loads them. Locally these come from the checkout; on Railway, which builds only this folder, from `main` on GitHub. The build fails if the README has no parity table, so a broken README never deploys. -- **Fetched by the page**: the releases (the download button picks the newest +- **Fetched by the page**: the registry's index (a project merged after the build shows at once), the releases (the download button picks the newest `launcher-v*` release's asset for the visitor's OS, the home page lists the newest game releases, and the Releases page reloads its list when GitHub has a newer release), players' benchmark results (`GET https://telemetry.playhorizon.dev/v1/benchmarks/summary`, 3-second timeout), the contributors (avatars come from `avatars.githubusercontent.com`), diff --git a/website/build.mjs b/website/build.mjs index 781e838bb8..0a813063b0 100644 --- a/website/build.mjs +++ b/website/build.mjs @@ -16,6 +16,7 @@ import { machinesHtml, playersHtml } from "./src/bench.js"; import { CPUS, GPUS, singleThread } from "./src/hardware.js"; import { isGame, isLauncher, launcherVersion, newestFirst } from "./src/versions.js"; import { contributorsFrom, contributorsHtml } from "./src/contributors.js"; +import { INDEX_URL, KINDS, SUBMIT, listHtml, projectHtml, readIndex } from "./src/mods.js"; const site = dirname(fileURLToPath(import.meta.url)); const dist = join(site, "dist"); @@ -41,6 +42,21 @@ try { console.warn(`links.json: ${e.message}; using the built-in links`); } +// Horizon's registry (/mods): the index its repository publishes, or HORIZON_MOD_INDEX (a file or an address) to +// try a test registry. Without an answer the pages are built empty and load the index themselves. +let modProjects = []; +let modIndex = null; +try { + const from = process.env.HORIZON_MOD_INDEX || INDEX_URL; + const text = from.startsWith("https://") + ? await fetch(from, { signal: AbortSignal.timeout(15000) }).then((r) => (r.ok ? r.text() : Promise.reject(new Error(`${r.status}`)))) + : await readFile(from, "utf8"); + modIndex = JSON.parse(text); + modProjects = readIndex(modIndex) ?? (console.warn("mod index: a schema this site doesn't read"), []); +} catch (e) { + console.warn(`mod index: ${e.message}; the pages will load it themselves`); +} + // An optional token (a Railway variable, or `gh auth token` locally) lifts the rate limit. const githubHeaders = { Accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) }; // Every release (the Releases page lists them all), a page of 100 at a time. @@ -170,7 +186,7 @@ await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32])); const names = {}; const rewrite = (code) => code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all)); // Dependencies before what imports them. -const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "main.js", "releases-page.js", "benchmarks-page.js"]; +const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "main.js", "releases-page.js", "benchmarks-page.js", "mods.js", "mods-page.js"]; for (const file of modules) { const code = rewrite(await readFile(join(site, "src", file), "utf8")); names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`; @@ -202,7 +218,7 @@ const baked = { const unavailable = `

The release notes could not be loaded here.

Read them on GitHub`; // The top bar and the footer of the pages beside the home page (Releases, Benchmarks). -const navLinks = [["/#about", "About"], ["/releases", "Releases"], ["/benchmarks", "Benchmarks"], ["/docs/", "Docs"]]; +const navLinks = [["/#about", "About"], ["/mods", "Mods"], ["/releases", "Releases"], ["/benchmarks", "Benchmarks"], ["/docs/", "Docs"]]; const siteNav = (current) => `