From f0d2269e8f98fedd1e906b171db407694b6f77ff Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 12 Jul 2026 00:28:45 +0200 Subject: [PATCH 01/21] chore(workspace): scaffold net crate for QUIC transport --- Cargo.lock | 716 +++++++++++++++++++++++++++++++++++- crates/net/Cargo.toml | 22 ++ crates/net/src/codec.rs | 5 + crates/net/src/endpoint.rs | 5 + crates/net/src/handshake.rs | 5 + crates/net/src/lib.rs | 12 + crates/net/src/runtime.rs | 5 + 7 files changed, 762 insertions(+), 8 deletions(-) create mode 100644 crates/net/Cargo.toml create mode 100644 crates/net/src/codec.rs create mode 100644 crates/net/src/endpoint.rs create mode 100644 crates/net/src/handshake.rs create mode 100644 crates/net/src/lib.rs create mode 100644 crates/net/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 1991352..04b62b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,45 @@ dependencies = [ "raw-window-metal", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert_type_match" version = "0.1.1" @@ -167,6 +206,35 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bevy_ecs" version = "0.19.0" @@ -329,6 +397,15 @@ dependencies = [ "thread_local", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -447,6 +524,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "client" version = "0.1.0" @@ -463,6 +551,15 @@ dependencies = [ "winit", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -481,7 +578,7 @@ dependencies = [ "bitflags 1.3.2", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "foreign-types", "libc", @@ -496,7 +593,7 @@ checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" dependencies = [ "bitflags 1.3.2", "block", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "libc", "objc", @@ -541,6 +638,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -554,7 +661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -567,7 +674,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ "libc", ] @@ -607,6 +723,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "2.1.1" @@ -636,6 +778,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "disqualified" version = "1.0.0" @@ -669,6 +822,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "embedded-io" version = "0.4.0" @@ -708,6 +867,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -759,6 +930,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -821,6 +998,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -840,8 +1030,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1062,6 +1255,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -1101,6 +1300,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1134,6 +1339,23 @@ dependencies = [ "libc", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1164,6 +1386,22 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "net" +version = "0.1.0" +dependencies = [ + "crossbeam-channel", + "postcard", + "quinn", + "rcgen", + "rustls", + "serde", + "shared", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "noise" version = "0.9.0" @@ -1171,10 +1409,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6da45c8333f2e152fc665d78a380be060eb84fad8ca4c9f7ac8ca29216cff0cc" dependencies = [ "num-traits", - "rand", + "rand 0.8.6", "rand_xorshift", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonmax" version = "0.5.5" @@ -1190,6 +1438,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1463,12 +1736,27 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.53" @@ -1494,6 +1782,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1580,6 +1878,12 @@ dependencies = [ "serde", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "presser" version = "0.3.1" @@ -1613,6 +1917,64 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "fastbloom", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1640,7 +2002,18 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1649,13 +2022,28 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1676,6 +2064,20 @@ dependencies = [ "raw-window-handle", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -1725,6 +2127,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1734,6 +2156,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1760,6 +2191,83 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1775,6 +2283,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1804,6 +2321,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1913,6 +2453,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1968,6 +2514,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -1998,6 +2554,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2009,6 +2571,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2071,6 +2644,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -2096,6 +2699,47 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2132,6 +2776,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2217,6 +2862,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uuid" version = "1.23.4" @@ -2262,6 +2913,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -2467,6 +3124,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wgpu-types" version = "29.0.4" @@ -2707,7 +3373,7 @@ dependencies = [ "calloop", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -2791,6 +3457,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -2816,6 +3500,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2836,6 +3530,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml new file mode 100644 index 0000000..3b4efbb --- /dev/null +++ b/crates/net/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "net" +license.workspace = true +license-file.workspace = true +authors.workspace = true +edition.workspace = true +version.workspace = true + +[dependencies] +crossbeam-channel = "0.5.16" +postcard.workspace = true +quinn = "0.11.11" +rcgen = "0.14.8" +rustls = "0.23.41" +serde.workspace = true +thiserror.workspace = true +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "sync", "io-util", "time"] } +tracing.workspace = true +shared = { path = "../shared" } + +[lints] +workspace = true diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs new file mode 100644 index 0000000..9ead433 --- /dev/null +++ b/crates/net/src/codec.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Length-prefixed `postcard` frame codec. +//! +//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream. Populated in a later concept; currently a placeholder. diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs new file mode 100644 index 0000000..7b302ef --- /dev/null +++ b/crates/net/src/endpoint.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! QUIC endpoint construction for client and server. +//! +//! Builds the `quinn` endpoints and configures ALPN and TLS 1.3 (self-signed server certificate, permissive client verifier for the initial milestone). Populated in a later concept; currently a placeholder. diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs new file mode 100644 index 0000000..09fe275 --- /dev/null +++ b/crates/net/src/handshake.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Synvael application handshake over an established QUIC connection. +//! +//! Drives the `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange and protocol-version verification on the control stream. Populated in a later concept; currently a placeholder. diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs new file mode 100644 index 0000000..40cae1d --- /dev/null +++ b/crates/net/src/lib.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! QUIC transport, connection lifecycle, and wire framing for the Synvael client-server protocol. +//! +//! This crate owns the asynchronous runtime and the transport dependencies, keeping them out of the lean `shared` protocol crate. Protocol message types live in `shared`; this crate is responsible only for carrying them over the wire. +//! +//! The synchronous simulation loop (`server`) and windowing loop (`client`) never touch the async runtime directly. They exchange messages with the network over channels, so the async runtime stays confined to this crate. + +pub mod codec; +pub mod endpoint; +pub mod handshake; +pub mod runtime; diff --git a/crates/net/src/runtime.rs b/crates/net/src/runtime.rs new file mode 100644 index 0000000..8f7328b --- /dev/null +++ b/crates/net/src/runtime.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Threaded `tokio` runtime bridge between the async network and the synchronous simulation. +//! +//! Owns the runtime thread and the channels that carry inbound and outbound messages across the async-sync boundary, keeping the async runtime confined to this crate. Populated in a later concept; currently a placeholder. From aaff38c7844cdca88f9c8ab00fd8efad8b92d42d Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 12 Jul 2026 00:28:45 +0200 Subject: [PATCH 02/21] docs(workspace): add ADR-0010 for net crate and async runtime --- AGENTS.md | 5 +++-- docs/adr/0010-net-crate-async-runtime.md | 27 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0010-net-crate-async-runtime.md diff --git a/AGENTS.md b/AGENTS.md index 49993cf..a917554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,15 +20,16 @@ The canonical game-*design* specification (intent, world rules, gameplay behavio ## Workspace layout -Cargo workspace (resolver = "3", edition 2024) with four crates under `crates/`: +Cargo workspace (resolver = "3", edition 2024) with six crates under `crates/`: - `client`: binary. Windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). Also pulls in `image`. Player-facing app titled "Synvael"; handles input, windowing, and drives the renderer. - `server`: binary. Authoritative game simulation (voxel world, combat, players). Used both for dedicated multiplayer hosts and as the simulation backend for single-player. - `renderer`: library. Voxel/scene rendering on Vulkan via `ash`, decoupled from windowing so it can be driven by `client`. - `shared`: library. Types and protocol shared between `client` and `server` (world/voxel data, network messages, combat primitives). Stays lean and dep-light; no `mlua`, no rendering, no engine internals. - `scripting`: library. Lua modding API and bindings (owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, mod loader). Both `client` and `server` depend on it. +- `net`: library. QUIC transport, connection lifecycle, and wire framing for the client↔server protocol; owns the async runtime (`tokio`) and the `quinn`/`rustls` dependencies. Both `client` and `server` depend on it. See [ADR-0010](docs/adr/0010-net-crate-async-runtime.md). -When adding code, keep the boundary tight: protocol/data types and game-rule primitives go in `shared`; Lua API surface and `mlua` integration in `scripting`; GPU/draw code in `renderer`; only input, windowing, and presentation glue live in `client`. Avoid growing `client` with simulation logic since it must work identically against either a local or remote `server`. +When adding code, keep the boundary tight: protocol/data types and game-rule primitives go in `shared`; Lua API surface and `mlua` integration in `scripting`; GPU/draw code in `renderer`; transport and connection code in `net` (protocol message *types* stay in `shared`); only input, windowing, and presentation glue live in `client`. Avoid growing `client` with simulation logic since it must work identically against either a local or remote `server`. ## Modding API (Lua): dogfooded diff --git a/docs/adr/0010-net-crate-async-runtime.md b/docs/adr/0010-net-crate-async-runtime.md new file mode 100644 index 0000000..593d7b1 --- /dev/null +++ b/docs/adr/0010-net-crate-async-runtime.md @@ -0,0 +1,27 @@ +# 0010. Dedicated `net` crate with a confined async runtime + +- **Status:** Accepted +- **Date:** 2026-07-12 + +## Context + +The network transport is QUIC via `quinn` which is an asynchronous library built on the `tokio` runtime and requires TLS 1.3 through `rustls`. These are heavy dependencies that pull an entire async ecosystem into the build. + +The `shared` crate is mandated to stay lean and dependency-light: it is the protocol/data layer, holding pure serde message types with no async, rendering, or engine internals. Placing transport code in `shared` would violate that mandate and force every consumer of the protocol types to compile `tokio` and `rustls`. At the same time, the simulation is synchronous: the `server` runs a synchronous `bevy_ecs` loop and the `client` runs a synchronous `winit` event loop. Introducing an async runtime must not turn those loops async or leak `tokio` throughout the workspace. + +## Decision + +Transport lives in a dedicated `net` crate, separate from `shared`, and the `tokio` runtime is confined to it. + +- `net` owns the `quinn`, `tokio`, and `rustls` dependencies, plus the QUIC endpoints, connection lifecycle, and wire framing. +- `shared` continues to hold only the protocol message *types* (serde, no async). +- Both `client` and `server` depend on `net`. +- The async runtime is bridged to the synchronous simulation over channels (`crossbeam-channel`), consistent with the message-passing concurrency model in `AGENTS.md`. The synchronous loops never `.await`; they send and receive protocol messages across the boundary. + +## Consequences + +- `shared` stays lean: consumers of the protocol types do not compile the async stack. +- The async surface is quarantined. Only `net` deals with `tokio`, keeping the `server` and `client` loops synchronous and unchanged. +- The workspace now has six crates. `net` sits between `shared` (types it carries) and the two binaries (which drive it). +- The channel bridge is an explicit boundary that must be maintained: work crossing between the async runtime and the sync simulation flows through channels, never through shared async state or by making the sim async. +- A crypto provider backend is required by `rustls`; the transport code must install one before building QUIC configuration. From fef3d1d5493c79da5de303535f2f960a5c6230f7 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 12 Jul 2026 00:50:01 +0200 Subject: [PATCH 03/21] chore(net): removed redundant licence-file --- crates/net/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml index 3b4efbb..fbfe08c 100644 --- a/crates/net/Cargo.toml +++ b/crates/net/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "net" license.workspace = true -license-file.workspace = true authors.workspace = true edition.workspace = true version.workspace = true From 41ba2e5d5c1f6f9bbfdbe4c46cf2848b7e71df9b Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 12 Jul 2026 13:33:36 +0200 Subject: [PATCH 04/21] feat(shared): add network control-protocol message types --- crates/shared/src/lib.rs | 1 + crates/shared/src/protocol.rs | 283 ++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 crates/shared/src/protocol.rs diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index cfa695b..f12ea88 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -5,5 +5,6 @@ //! This crate contains data structures and constants that are used by both the client and the server. pub mod generator; +pub mod protocol; pub mod save; pub mod world; diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs new file mode 100644 index 0000000..399d73c --- /dev/null +++ b/crates/shared/src/protocol.rs @@ -0,0 +1,283 @@ +//! Network protocol types and constants. +//! +//! This module defines the pure, serde-serializable messages used for network communication between the client and server. It contains no networking logic or async dependencies. + +use serde::{Deserialize, Serialize}; + +/// Wire-protocol version. Incremented on any breaking change to the message layout below. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Messages carried on the control stream (stream 0): handshake and disconnect. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ControlMessage { + /// First message a client sends after the QUIC/TLS handshake. + ClientHello(ClientHello), + /// Server acceptance carrying negotiated session parameters. + HandshakeAck(HandshakeAck), + /// Server refusal with a machine-readable reason. + HandshakeReject(HandshakeReject), + /// Orderly session teardown initiated by either side. + Disconnect(Disconnect), +} + +/// First message a client sends after the QUIC/TLS handshake. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ClientHello { + /// Protocol version the client was built against; compared to `PROTOCOL_VERSION`. + pub protocol_version: u32, + /// Human-readable client build string (e.g. crate version + git hash). + pub client_build: String, + /// Identity the player presents. Minimal for M1. + pub player_identity: PlayerIdentity, + /// Content packs the client has installed. Empty in M1; validated later. + pub installed_packs: Vec, + /// Optional protocol feature bits the client requests. Zero in M1. + pub requested_features: FeatureFlags, +} + +/// Server acceptance carrying negotiated session parameters. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HandshakeAck { + /// Server's protocol version (equal to the client's on success). + pub protocol_version: u32, + /// Human-readable server build string. + pub server_build: String, + /// Packs the world requires, each with an optional download source. Empty in M1. + pub world_packs: Vec, + /// Packs the client is missing relative to the server, each with an optional download source. Empty in M1. + pub missing_packs: Vec, + /// Which stream carries which purpose for this session. + pub stream_layout: StreamLayout, + /// Advisory server tick rate in Hz, for client clock setup. + pub tick_rate_hint: u16, +} + +/// Server refusal with a machine-readable reason. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HandshakeReject { + /// Machine-readable rejection category. + pub reason: RejectReason, + /// Human-readable detail for logs and UI. + pub detail: String, + /// Optional URL directing the user to a compatible build or pack, when the rejection is recoverable (e.g. `ProtocolMismatch`, `PackMismatch`). + pub upgrade_url: Option, +} + +/// Orderly session teardown initiated by either side. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Disconnect { + /// Human-readable reason shown to the peer and logged. + pub reason: String, +} + +/// Machine-readable categories for handshake rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RejectReason { + /// Client protocol version does not match the server's. + ProtocolMismatch, + /// Client is missing required packs or has incompatible versions. + PackMismatch, + /// Client failed to authenticate. + AuthFailed, + /// Client is banned from the server. + Banned, + /// Server is full. + Full, + /// Server encountered an internal error during handshake. + ServerError, +} + +/// Identity presented by the player to the server. +// TODO: use authenticated identity once the Account system exists. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PlayerIdentity { + /// Human-readable display name. + pub display_name: String, +} + +/// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake. +/// +/// Identity is the pair (`id`, `content_hash`): `id` names the pack and `content_hash` is the authoritative value compared when deciding whether two installations agree. `version` is informational (display, logs, upgrade prompts) and is **not** the match key — two builds sharing a version but differing in contents are distinct packs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PackRef { + /// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope). + pub id: String, + /// Human-readable semantic version. Informational only; not the match key. + pub version: String, + /// Canonical hash of the pack contents; the authoritative match key. + // TODO: pin the canonical hashing procedure (traversal order, newline normalization) so independent builds of one pack hash identically. + pub content_hash: [u8; 32], + /// Tier the pack was classified into, which governs whether a client/server mismatch on this pack is fatal or tolerated. Inferred by the owner from the pack's folder contents (see Load order), never self-declared. + pub tier: PackTier, +} + +/// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum PackTier { + /// Client-only asset overlay (`assets/` only). Never streamed by the server, never matched. + Resource, + /// Declarative content (`data/` only). Must match exactly between peers. + Data, + /// Lua mod (`scripts/`, optionally `data/` and `assets/`); full API access. + Mod { + /// Set when the mod ships no `data/` and every system is `scope = "client"`, so a client/server mismatch on it cannot desync authoritative state and is therefore tolerated. Not trusted blindly by the server for packs carrying data or server-scoped systems. + client_only: bool, + }, +} + +impl PackTier { + /// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly (see Load order § modlist matching). + #[must_use] + pub fn requires_strict_match(self) -> bool { + match self { + PackTier::Resource => false, + PackTier::Data => true, + PackTier::Mod { client_only } => !client_only, + } + } +} + +/// A pack the server's world requires, paired with an optional out-of-band download source. Sent server → client in the handshake; the client fetches any it lacks via the URL when present, otherwise over the QUIC asset stream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RequiredPack { + /// Identity and tier of the required pack. + pub pack: PackRef, + /// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream. + pub download_url: Option, +} + +/// Optional protocol feature bits. +#[repr(transparent)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct FeatureFlags(pub u32); + +/// Mapping of logical purposes to QUIC stream IDs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StreamLayout { + /// Stream ID for control messages (handshake, disconnect). + pub control: u8, + /// Stream ID for client input to server. + pub input: u8, + /// Stream ID for server authoritative state updates. + pub authority: u8, + /// Stream ID for highest detail chunk updates (LOD0). + pub chunk_lod0: u8, + /// Stream ID for chunk updates (LOD1). + pub chunk_lod1: u8, + /// Stream ID for chunk updates (LOD2). + pub chunk_lod2: u8, + /// Stream ID for chunk updates (LOD3). + pub chunk_lod3: u8, + /// Stream ID for lowest detail chunk updates (LOD4). + pub chunk_lod4: u8, + /// Stream ID for downloading assets. + pub asset: u8, + /// Stream ID for downloading mod scripts. + pub mod_data: u8, +} + +impl Default for StreamLayout { + fn default() -> Self { + Self { + control: 0, + input: 1, + authority: 2, + chunk_lod0: 3, + chunk_lod1: 4, + chunk_lod2: 5, + chunk_lod3: 6, + chunk_lod4: 7, + asset: 8, + mod_data: 9, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stream_layout_default_is_canonical() { + let layout = StreamLayout::default(); + assert_eq!(layout.control, 0); + assert_eq!(layout.input, 1); + assert_eq!(layout.authority, 2); + assert_eq!(layout.chunk_lod0, 3); + assert_eq!(layout.chunk_lod1, 4); + assert_eq!(layout.chunk_lod2, 5); + assert_eq!(layout.chunk_lod3, 6); + assert_eq!(layout.chunk_lod4, 7); + assert_eq!(layout.asset, 8); + assert_eq!(layout.mod_data, 9); + } + + fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> { + let bytes = postcard::to_stdvec(msg)?; + let decoded: ControlMessage = postcard::from_bytes(&bytes)?; + assert_eq!(msg, &decoded); + Ok(()) + } + + #[test] + fn roundtrip_client_hello() -> Result<(), postcard::Error> { + let msg = ControlMessage::ClientHello(ClientHello { + protocol_version: PROTOCOL_VERSION, + client_build: "synvael-client-0.1.0".to_string(), + player_identity: PlayerIdentity { + display_name: "Player1".to_string(), + }, + installed_packs: vec![PackRef { + id: "core:base".to_string(), + version: "1.0.0".to_string(), + content_hash: [0; 32], + tier: PackTier::Data, + }], + requested_features: FeatureFlags(0), + }); + roundtrip_test(&msg) + } + + #[test] + fn roundtrip_handshake_ack() -> Result<(), postcard::Error> { + let msg = ControlMessage::HandshakeAck(HandshakeAck { + protocol_version: PROTOCOL_VERSION, + server_build: "synvael-server-0.1.0".to_string(), + world_packs: vec![], + missing_packs: vec![], + stream_layout: StreamLayout::default(), + tick_rate_hint: 20, + }); + roundtrip_test(&msg) + } + + #[test] + fn roundtrip_handshake_reject() -> Result<(), postcard::Error> { + let msg = ControlMessage::HandshakeReject(HandshakeReject { + reason: RejectReason::ProtocolMismatch, + detail: "Expected v1, got v2".to_string(), + upgrade_url: Some("https://synvael.example/download".to_string()), + }); + roundtrip_test(&msg) + } + + #[test] + fn pack_tier_match_rules() { + // Resource packs are client-side overlays: a mismatch is always tolerated. + assert!(!PackTier::Resource.requires_strict_match()); + // Data packs affect authoritative content and must match exactly. + assert!(PackTier::Data.requires_strict_match()); + // A client-only mod cannot desync server state, so a mismatch is tolerated. + assert!(!PackTier::Mod { client_only: true }.requires_strict_match()); + // A server-affecting mod must match exactly. + assert!(PackTier::Mod { client_only: false }.requires_strict_match()); + } + + #[test] + fn roundtrip_disconnect() -> Result<(), postcard::Error> { + let msg = ControlMessage::Disconnect(Disconnect { + reason: "Server closing".to_string(), + }); + roundtrip_test(&msg) + } +} From 5c61ecc209354c70599be1e66d2b53acc653887e Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 12 Jul 2026 15:49:33 +0200 Subject: [PATCH 05/21] docs(workspace): adopt declarative-first principle for content --- docs/adr/0007-declarative-content-via-modding-api.md | 3 +++ docs/packs.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/adr/0007-declarative-content-via-modding-api.md b/docs/adr/0007-declarative-content-via-modding-api.md index 20e4d1c..0e9a5b4 100644 --- a/docs/adr/0007-declarative-content-via-modding-api.md +++ b/docs/adr/0007-declarative-content-via-modding-api.md @@ -15,11 +15,14 @@ The data-pack loader reads the declarative files and **calls the same Lua API** Each data-pack schema is treated as a stable contract, versioned as deliberately as the Lua API. +**Amendment (declarative-first):** the choice between JSON and Lua is not free per content item. Anything expressible as data — the static fields of a block, item, recipe, loot table, biome, or tag — is authored as data and lives in `data/`; Lua is reserved for behavior (logic that runs on an event or tick). A pure-data block therefore needs no Lua at all. Consequently a data pack *can* register a block, item, or other primitive on its own, provided that primitive is purely declarative; the moment it needs behavior, that behavior half comes from a Lua mod. To avoid hand-authoring large volumes of near-identical files, modders may use **datagen**: code that emits `data/` files at build time, on the author's machine, before the pack ships. Datagen output (not its code) is the shipped artifact, and never runs at load time, so the single runtime load path is preserved. + The canonical load order, later layers overriding earlier ones, is: base game → data packs → Lua mods → resource packs (resource packs last so client visuals win). ## Consequences - One source of truth for registration; declarative content and scripted content cannot diverge in behaviour because they end at the same API. +- Declarative-first means data is the default and code the exception: a new primitive gets a data schema first, and a Lua-only registration path signals a gap in that schema. The base game dogfoods the datapack path, keeping pure-data `core` content in `data/` and only behavioural systems in `scripts/`. - Accepting a schema is a long-lived commitment, since data packs in the wild depend on it. - Resource packs remain entirely client-side with no server involvement, and are kept conceptually separate from data packs. - Full subsystem detail (load order, repo and user-data layout, resolution semantics) lives in [`docs/packs.md`](../packs.md). diff --git a/docs/packs.md b/docs/packs.md index bbcaf83..fb8af51 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -18,7 +18,9 @@ No parallel registration system is built. The loader reads the declarative files data/blocks/stone.json → loader → blocks.register{ id = "stone", ... } ``` -The loader belongs in `scripting` (or a sibling crate if it grows). Engine first-party content may use either JSON or Lua, whichever fits. Every data-pack schema is a stable contract, the same as the Lua API, version it deliberately. +The loader belongs in `scripting` (or a sibling crate if it grows). Every data-pack schema is a stable contract, the same as the Lua API, version it deliberately. + +**Declarative-first (ADR-0007):** JSON and Lua are not free alternatives. Anything expressible as data — the static fields of a block, item, recipe, loot table, biome, or tag — is authored as data in `data/`; Lua is reserved for behavior (logic that runs on an event or tick). A pure-data block therefore needs no Lua, and a data pack can register such a primitive on its own; only its behavior half (if any) comes from a Lua mod. Engine first-party content follows the same rule, keeping pure-data `core` content in `data/` and only behavioral systems in `scripts/`. To avoid hand-authoring large volumes of near-identical files, modders may use **datagen**: code that emits `data/` files at build time, before the pack ships — its output, not its code, is the shipped artifact, and it never runs at load time. ## Canonical load order From 339c0efe5a566e80c5ec3a18bf046fda74331199 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 12 Jul 2026 15:49:33 +0200 Subject: [PATCH 06/21] feat(shared): support server-pushed resource packs --- crates/shared/src/protocol.rs | 16 ++++++++-------- docs/packs.md | 4 +++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs index 399d73c..570e998 100644 --- a/crates/shared/src/protocol.rs +++ b/crates/shared/src/protocol.rs @@ -1,6 +1,4 @@ //! Network protocol types and constants. -//! -//! This module defines the pure, serde-serializable messages used for network communication between the client and server. It contains no networking logic or async dependencies. use serde::{Deserialize, Serialize}; @@ -42,9 +40,9 @@ pub struct HandshakeAck { pub protocol_version: u32, /// Human-readable server build string. pub server_build: String, - /// Packs the world requires, each with an optional download source. Empty in M1. + /// Packs the world requires, each with an optional download source. May include `PackTier::Resource` entries (a server resource pack), which are delivered one-way and applied client-side rather than strict-matched; a consumer must branch on tier (or `PackTier::requires_strict_match`) before treating an entry as a match requirement. pub world_packs: Vec, - /// Packs the client is missing relative to the server, each with an optional download source. Empty in M1. + /// Packs the client is missing relative to the server, each with an optional download source. As with `world_packs`, `PackTier::Resource` entries are delivered, not matched. pub missing_packs: Vec, /// Which stream carries which purpose for this session. pub stream_layout: StreamLayout, @@ -77,6 +75,8 @@ pub enum RejectReason { ProtocolMismatch, /// Client is missing required packs or has incompatible versions. PackMismatch, + /// Client declined or failed to fetch a server resource pack the server marked required. + ResourcePackDeclined, /// Client failed to authenticate. AuthFailed, /// Client is banned from the server. @@ -96,8 +96,6 @@ pub struct PlayerIdentity { } /// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake. -/// -/// Identity is the pair (`id`, `content_hash`): `id` names the pack and `content_hash` is the authoritative value compared when deciding whether two installations agree. `version` is informational (display, logs, upgrade prompts) and is **not** the match key — two builds sharing a version but differing in contents are distinct packs. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PackRef { /// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope). @@ -114,7 +112,7 @@ pub struct PackRef { /// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum PackTier { - /// Client-only asset overlay (`assets/` only). Never streamed by the server, never matched. + /// Client-side asset overlay (`assets/` only). Never strict-matched between peers. A server may push one server resource pack of its own, delivered one-way and applied on top of the client's local pack stack; enforcement of a `required` server pack is apply-or-reject at the client, not a peer hash-match. Resource, /// Declarative content (`data/` only). Must match exactly between peers. Data, @@ -126,7 +124,7 @@ pub enum PackTier { } impl PackTier { - /// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly (see Load order § modlist matching). + /// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly. A `false` here does not imply the server never sends the pack, a server resource pack is delivered one-way despite not being part of bidirectional matching. #[must_use] pub fn requires_strict_match(self) -> bool { match self { @@ -144,6 +142,8 @@ pub struct RequiredPack { pub pack: PackRef, /// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream. pub download_url: Option, + /// Whether the connection is rejected if the client cannot obtain and apply this pack. For data/mod tiers this is always `true` (they are mandatory for a correct session). For a `PackTier::Resource` entry (a server resource pack) it distinguishes an *optional* overlay the client may decline and keep playing (`false`) from a *required* one whose decline or fetch failure rejects the connection (`true`). + pub required: bool, } /// Optional protocol feature bits. diff --git a/docs/packs.md b/docs/packs.md index fb8af51..3fae72e 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -6,7 +6,9 @@ Two distinct, orthogonal systems. They are kept separate and are not collapsed i Client-side asset overlays: textures, sounds, models, fonts, language files. No logic. -A pack is a directory tree mirroring `/assets/` that overrides files by path. The renderer/asset loader resolves logical asset IDs against a stack of pack roots (base game → installed packs by priority) and the topmost hit wins. The server has no involvement. Ownership sits with the asset pipeline (in `client`, or a sibling `assets` crate if it grows). Pack authors never touch Lua. +A pack is a directory tree mirroring `/assets/` that overrides files by path. The renderer/asset loader resolves logical asset IDs against a stack of pack roots (base game → installed packs by priority) and the topmost hit wins. Ownership sits with the asset pipeline (in `client`, or a sibling `assets` crate if it grows). Pack authors never touch Lua. + +A client's own resource packs are a purely local choice; the server has no say over them and they are never part of gameplay modlist matching. The **one** exception is a **server resource pack**: a server may push a single cosmetic overlay of its own (a themed / total-conversion server) to connecting clients. It is a one-way server → client push, applied on top of the client's local stack, and enforced per the server's choice — *optional* packs the client may decline and keep playing, a *required* pack the client declines or fails to fetch rejects the connection. It is still `assets/`-only (no `data/`, no `scripts/`), so it can never affect authoritative state. Fetch and enforcement semantics live in the vault's `Architecture/Load order.md` § Streaming. ## Data packs From f7e986cb24044f836e244c27c981b17cc996bf41 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 00:27:51 +0200 Subject: [PATCH 07/21] feat(net): add length-prefixed postcard frame codec --- crates/net/src/codec.rs | 256 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 255 insertions(+), 1 deletion(-) diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index 9ead433..a715778 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -2,4 +2,258 @@ //! Length-prefixed `postcard` frame codec. //! -//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream. Populated in a later concept; currently a placeholder. +//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream. + +/// Errors produced by the framing codec and its stream I/O helpers. +#[derive(Debug, thiserror::Error)] +pub enum NetError { + /// A `postcard` serialization or deserialization operation failed. + #[error("postcard codec error: {0}")] + Postcard(#[from] postcard::Error), + /// An underlying byte-stream I/O operation failed. + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + /// A declared frame length exceeded the caller-supplied maximum, indicating a malicious or corrupt peer. + #[error("frame length {len} exceeds maximum {max}")] + FrameTooLarge { + /// The frame length declared by the length prefix, in bytes. + len: u64, + /// The maximum payload length accepted by the reader, in bytes. + max: usize, + }, + /// The stream ended before a complete frame (prefix or payload) had been read. + #[error("unexpected end of stream while reading a frame")] + UnexpectedEof, + /// A varint length prefix was malformed: either overlong or otherwise invalid. + #[error("malformed varint length prefix")] + MalformedVarint, + /// Writing bytes to a quinn send stream failed. + #[error("quinn write error: {0}")] + Write(#[from] quinn::WriteError), + /// Reading an exact number of bytes from a quinn recv stream failed. + #[error("quinn read error: {0}")] + Read(#[from] quinn::ReadExactError), +} + +/// The maximum payload length, in bytes, accepted on the control stream (64 KiB), matching the mod-payload cap. Higher-bandwidth tiers such as chunk streaming define their own caps. +pub const MAX_CONTROL_FRAME_LEN: usize = 64 * 1024; + +/// The maximum number of bytes an unsigned LEB128 varint may occupy for a `u64` value (`ceil(64 / 7)`). +const MAX_VARINT_LEN: usize = 10; + +/// Appends `value` to `buf` as an unsigned LEB128 varint. +/// +/// Each byte carries seven value bits in little-endian group order; the high bit (`0x80`) is a continuation flag set on every byte except the last. +fn write_varint(value: u64, buf: &mut Vec) { + let mut remaining = value; + loop { + // Extract the low seven bits of the remaining value. + let mut byte = (remaining & 0x7f) as u8; + remaining >>= 7; + if remaining != 0 { + // Further bytes follow, so mark the continuation bit. + byte |= 0x80; + } + buf.push(byte); + if remaining == 0 { + break; + } + } +} + +/// Reads an unsigned LEB128 varint from the front of `bytes`, returning the decoded value and the number of bytes consumed. +fn read_varint(bytes: &[u8]) -> Result<(u64, usize), NetError> { + let mut value: u64 = 0; + let mut shift: u32 = 0; + for (index, &byte) in bytes.iter().enumerate() { + if index >= MAX_VARINT_LEN { + return Err(NetError::MalformedVarint); + } + // Accumulate the seven payload bits at their little-endian position. + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + shift += 7; + } + // The continuation bit was still set when the buffer ran out. + Err(NetError::UnexpectedEof) +} + +/// Encodes `msg` as a single length-prefixed `postcard` frame into a freshly allocated buffer. +fn encode_frame(msg: &T) -> Result, NetError> { + let payload = postcard::to_stdvec(msg)?; + let mut frame = Vec::new(); + write_varint(payload.len() as u64, &mut frame); + frame.extend_from_slice(&payload); + Ok(frame) +} + +/// Writes one length-prefixed `postcard` frame to a quinn send stream. +pub async fn write_frame( + stream: &mut quinn::SendStream, + msg: &T, +) -> Result<(), NetError> { + let frame = encode_frame(msg)?; + stream.write_all(&frame).await?; + Ok(()) +} + +/// Reads one length-prefixed `postcard` frame from a quinn recv stream and decodes it. +pub async fn read_frame( + stream: &mut quinn::RecvStream, + max_len: usize, +) -> Result { + // The prefix length is not known in advance, so bytes are pulled one at a time until a byte without the continuation flag is read, then decoded by the shared pure helper. + let mut prefix = Vec::with_capacity(MAX_VARINT_LEN); + loop { + let mut byte = [0u8; 1]; + stream.read_exact(&mut byte).await?; + prefix.push(byte[0]); + if byte[0] & 0x80 == 0 { + break; + } + if prefix.len() > MAX_VARINT_LEN { + return Err(NetError::MalformedVarint); + } + } + let (len, _consumed) = read_varint(&prefix)?; + + // The declared length is validated before allocating the payload buffer. + let checked_len = check_frame_len(len, max_len)?; + let mut payload = vec![0u8; checked_len]; + stream.read_exact(&mut payload).await?; + Ok(postcard::from_bytes(&payload)?) +} + +/// Validates a declared frame length against `max_len`. +fn check_frame_len(len: u64, max_len: usize) -> Result { + if len > max_len as u64 { + return Err(NetError::FrameTooLarge { len, max: max_len }); + } + #[expect( + clippy::cast_possible_truncation, + reason = "len <= max_len (usize) checked above" + )] + Ok(len as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::protocol::{ + ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, + }; + + /// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed. + #[test] + fn varint_round_trip_boundaries() -> Result<(), NetError> { + let cases = [ + 0, + 1, + 127, + 128, + 16_383, + 16_384, + u64::from(u32::MAX), + u64::MAX, + ]; + for value in cases { + let mut buf = Vec::new(); + write_varint(value, &mut buf); + let (decoded, consumed) = read_varint(&buf)?; + assert_eq!(decoded, value, "decoded value mismatch"); + assert_eq!( + consumed, + buf.len(), + "consumed length must equal encoded length" + ); + } + Ok(()) + } + + /// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count. + #[test] + fn encode_frame_prefixes_payload_length() -> Result<(), NetError> { + let msg = ControlMessage::ClientHello(ClientHello { + protocol_version: PROTOCOL_VERSION, + client_build: "synvael-client-0.1.0".to_string(), + player_identity: PlayerIdentity { + display_name: "Player1".to_string(), + }, + installed_packs: Vec::new(), + requested_features: FeatureFlags(0), + }); + let payload = postcard::to_stdvec(&msg)?; + let frame = encode_frame(&msg)?; + + let (declared_len, prefix_bytes) = read_varint(&frame)?; + assert_eq!( + declared_len, + payload.len() as u64, + "prefix must equal payload length" + ); + assert_eq!( + &frame[prefix_bytes..], + payload.as_slice(), + "payload bytes must follow the prefix unchanged" + ); + Ok(()) + } + + /// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly. + #[test] + fn varint_boundary_lengths() { + let mut buf = Vec::new(); + write_varint(127, &mut buf); + assert_eq!(buf.len(), 1, "127 must encode in a single byte"); + + buf.clear(); + write_varint(128, &mut buf); + assert_eq!(buf.len(), 2, "128 must encode in two bytes"); + } + + /// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic. + #[test] + fn varint_truncated_is_error() { + // Two bytes both flagged as "continued", with no terminating byte. + let truncated = [0x80u8, 0x80u8]; + assert!( + read_varint(&truncated).is_err(), + "truncated varint must return an error" + ); + } + + /// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted. + #[test] + fn varint_overlong_is_error() { + // Eleven continuation bytes followed by a terminator exceeds the u64 limit. + let overlong = [0x80u8; 11]; + assert!( + read_varint(&overlong).is_err(), + "overlong varint must return an error" + ); + } + + /// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation. + #[test] + fn frame_len_bound_is_enforced() { + assert_eq!( + check_frame_len(64, 128).ok(), + Some(64), + "a length within the cap is accepted" + ); + assert_eq!( + check_frame_len(128, 128).ok(), + Some(128), + "a length equal to the cap is accepted" + ); + assert!( + matches!( + check_frame_len(129, 128), + Err(NetError::FrameTooLarge { len: 129, max: 128 }) + ), + "a length over the cap must be rejected", + ); + } +} From 8a5c662e10d9cb88900e1672e276afe97f3b2b6c Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 00:39:49 +0200 Subject: [PATCH 08/21] chore(client): add reasons to expect attributes --- crates/client/src/camera.rs | 5 ++++- crates/client/src/main.rs | 21 ++++++++++++++++----- crates/client/src/meshing.rs | 3 ++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/client/src/camera.rs b/crates/client/src/camera.rs index 6c32a2d..466baab 100644 --- a/crates/client/src/camera.rs +++ b/crates/client/src/camera.rs @@ -59,7 +59,10 @@ impl Camera { /// Advances the camera by a single frame, applying `input` accumulated over `dt` seconds. pub fn update(&mut self, input: &InputState, dt: f32) { // Apply accumulated mouse motion to the orientation. A downward mouse delta (positive y) lowers the pitch, so the vertical term is subtracted. - #[expect(clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_truncation, + reason = "mouse deltas are small; f32 precision is sufficient for camera input" + )] { self.yaw += input.mouse_delta.0 as f32 * self.sensitivity; self.pitch -= input.mouse_delta.1 as f32 * self.sensitivity; diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 1231e40..e1683aa 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -22,8 +22,10 @@ use winit::window::{CursorGrabMode, Window, WindowId}; /// Transient per-frame input state sampled from window and device events. /// /// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates raw pointer motion between frames and is consumed (reset to zero) once applied to the camera. -// The bools are independent per-key held states, for which a flat struct is the clearest form. -#[expect(clippy::struct_excessive_bools)] +#[expect( + clippy::struct_excessive_bools, + reason = "per-key held states are independent; a flat bool struct is the clearest representation" +)] #[derive(Default)] struct InputState { /// Whether the "move forward" key (W) is held. @@ -141,10 +143,16 @@ impl ApplicationHandler for App { self.window = Some(window); self.renderer = Some(renderer); - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "startup asset load; a missing worldgen config is unrecoverable at launch" + )] let config_str = std::fs::read_to_string("assets/data/worldgen/default.json") .expect("Failed to read worldgen config"); - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "startup config parse; a malformed worldgen config is unrecoverable at launch" + )] let worldgen_config: shared::generator::WorldGenConfig = serde_json::from_str(&config_str).expect("Failed to parse worldgen config"); @@ -160,7 +168,10 @@ impl ApplicationHandler for App { indices.len() ); - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "the renderer is assigned earlier in this function" + )] self.renderer .as_mut() .expect("Renderer initialized") diff --git a/crates/client/src/meshing.rs b/crates/client/src/meshing.rs index 4e40355..da41f3d 100644 --- a/crates/client/src/meshing.rs +++ b/crates/client/src/meshing.rs @@ -6,7 +6,8 @@ use shared::world::{BlockId, CHUNK_SIZE, Chunk}; #[expect( clippy::cast_precision_loss, clippy::cast_possible_truncation, - clippy::too_many_lines + clippy::too_many_lines, + reason = "voxel coordinates and vertex counts are small and lossless as f32/u32; the per-face unrolling is intentionally long" )] pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { let mut vertices = Vec::new(); From 6b3ae06031d7f407605121c9d4570cd7431daffa Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 00:39:49 +0200 Subject: [PATCH 09/21] chore(renderer): add reasons to expect attributes --- crates/renderer/src/device.rs | 5 ++++- crates/renderer/src/lib.rs | 5 ++++- crates/renderer/src/mesh.rs | 5 ++++- crates/renderer/src/pipeline.rs | 5 ++++- crates/renderer/src/renderer.rs | 24 ++++++++++++++++++------ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/renderer/src/device.rs b/crates/renderer/src/device.rs index 84fb199..a4d43d8 100644 --- a/crates/renderer/src/device.rs +++ b/crates/renderer/src/device.rs @@ -67,7 +67,10 @@ pub fn find_graphics_queue_family( let props = unsafe { instance.get_physical_device_queue_family_properties(physical_device) }; for (index, prop) in props.iter().enumerate() { - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "a physical device's queue-family count never approaches u32::MAX" + )] let index = u32::try_from(index).expect("Queue family index exceeds u32 range"); let graphics = prop.queue_flags.contains(vk::QueueFlags::GRAPHICS); let present = unsafe { diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 7f21a07..e9d5442 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -96,7 +96,10 @@ impl Renderer { let command_pool = unsafe { device.create_command_pool(&pool_create_info, None)? }; // 9. Command Buffers - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "MAX_FRAMES_IN_FLIGHT is a small compile-time constant" + )] let alloc_info = vk::CommandBufferAllocateInfo::default() .command_pool(command_pool) .level(vk::CommandBufferLevel::PRIMARY) diff --git a/crates/renderer/src/mesh.rs b/crates/renderer/src/mesh.rs index 048f7b8..6117cea 100644 --- a/crates/renderer/src/mesh.rs +++ b/crates/renderer/src/mesh.rs @@ -24,7 +24,10 @@ impl Vertex { /// /// # Panics /// Panics if the size of the vertex structure exceeds the maximum value of a 32-bit unsigned integer. - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "the vertex struct size is far below u32::MAX" + )] pub fn get_binding_description() -> ash::vk::VertexInputBindingDescription { ash::vk::VertexInputBindingDescription::default() .binding(0) diff --git a/crates/renderer/src/pipeline.rs b/crates/renderer/src/pipeline.rs index dbfa789..4668ab5 100644 --- a/crates/renderer/src/pipeline.rs +++ b/crates/renderer/src/pipeline.rs @@ -29,7 +29,10 @@ pub fn create_shader_module( /// This layout defines any push constants or descriptor sets (textures/UBOs) accessed by the shaders during execution. pub fn create_pipeline_layout(device: &Device) -> Result { // A single push constant range is defined for the MVP matrix, allowing it to be updated for every draw call with high efficiency. - #[expect(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "size_of::() is 64 bytes, well within u32 range" + )] let push_constant_range = vk::PushConstantRange::default() .stage_flags(vk::ShaderStageFlags::VERTEX) .offset(0) diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index b357a92..b8bf83e 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -17,14 +17,17 @@ pub struct Renderer { /// The debug messenger for validation layer output. pub(crate) debug_messenger: vk::DebugUtilsMessengerEXT, /// Handle to the selected physical device (GPU). - #[expect(dead_code)] + #[expect(dead_code, reason = "retained for later device-capability queries")] pub(crate) physical_device: vk::PhysicalDevice, /// The logical Vulkan device. pub(crate) device: Device, /// The queue used for graphics operations. pub(crate) graphics_queue: vk::Queue, /// Index of the graphics queue family. - #[expect(dead_code)] + #[expect( + dead_code, + reason = "retained for later queue-family-dependent operations" + )] pub(crate) graphics_queue_index: u32, /// Surface extension loader. pub(crate) surface_loader: khr::surface::Instance, @@ -37,7 +40,7 @@ pub struct Renderer { /// Images acquired from the swapchain. pub(crate) swapchain_images: Vec, /// The pixel format of the swapchain images. - #[expect(dead_code)] + #[expect(dead_code, reason = "retained for later swapchain recreation")] pub(crate) swapchain_format: vk::Format, /// The dimensions of the swapchain images. pub(crate) swapchain_extent: vk::Extent2D, @@ -262,7 +265,10 @@ impl Renderer { self.graphics_pipeline, ); - #[expect(clippy::cast_precision_loss)] + #[expect( + clippy::cast_precision_loss, + reason = "swapchain extents are within f32's exact-integer range" + )] let viewport = vk::Viewport { x: 0.0, y: 0.0, @@ -287,7 +293,10 @@ impl Renderer { let aspect = f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); - #[expect(clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_truncation, + reason = "the aspect ratio is a small value; f32 precision is sufficient" + )] let projection = glam::camera::rh::proj::vulkan::perspective( 45.0_f32.to_radians(), aspect as f32, @@ -351,7 +360,10 @@ impl Renderer { /// /// # Errors /// Returns a `RendererError` if new Vulkan buffers cannot be allocated or created. - #[expect(clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_truncation, + reason = "a chunk mesh's index count never approaches u32::MAX" + )] pub fn update_mesh( &mut self, vertices: &[Vertex], From 343ea7605b3c47235ef43aebc0952b59a4342eaf Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 00:39:49 +0200 Subject: [PATCH 10/21] chore(server): add reasons to expect attributes --- crates/server/src/save/region_actor.rs | 6 ++++-- crates/server/src/world_server.rs | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/server/src/save/region_actor.rs b/crates/server/src/save/region_actor.rs index 77e7b02..07430d7 100644 --- a/crates/server/src/save/region_actor.rs +++ b/crates/server/src/save/region_actor.rs @@ -47,8 +47,10 @@ pub struct SaveActor { /// The sending end of the request channel; cloned into every worker so it can issue reads. request_tx: Sender, /// The actor thread handle, retained so it can be joined on shutdown. - // * Retained ahead of a dedicated shutdown path; not yet read because the server has no graceful-stop sequence. - #[expect(dead_code)] + #[expect( + dead_code, + reason = "retained for a future graceful-shutdown join path" + )] handle: JoinHandle<()>, } diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index 561ae5a..09a7aec 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -46,13 +46,17 @@ pub struct ServerWorld { /// Positions dispatched to a worker but not yet returned, preventing the same chunk being re-dispatched on subsequent passes. in_flight: HashSet, /// The dedicated thread owning all region files, kept alive for the world's lifetime. - // Retained so its request channel stays open for the workers; not read again after construction. - #[expect(dead_code)] + #[expect( + dead_code, + reason = "retained to keep the save request channel open for the workers" + )] save_actor: SaveActor, /// Handles to the generation worker threads, retained so they can be joined on shutdown. - // * NOTE: Retained ahead of a dedicated shutdown path // TODO: Remove once the server has a graceful-stop sequence - #[expect(dead_code)] + #[expect( + dead_code, + reason = "retained for a future graceful-shutdown join path" + )] workers: Vec>, /// The same read-only generator handle the workers share, held so eviction can regenerate a chunk's baseline to diff against. generator: Arc, From f83a0f1232d6f8fd5021fa79aadba24ec60d6ebe Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 00:39:49 +0200 Subject: [PATCH 11/21] chore(shared): add reasons to expect attributes --- crates/shared/src/generator.rs | 6 +++++- crates/shared/src/world/chunk.rs | 10 ++++++++-- crates/shared/src/world/chunk_data.rs | 6 ++++-- crates/shared/src/world/coords.rs | 6 +++++- crates/shared/src/world/entity.rs | 12 +++++++++--- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/shared/src/generator.rs b/crates/shared/src/generator.rs index d180459..3623368 100644 --- a/crates/shared/src/generator.rs +++ b/crates/shared/src/generator.rs @@ -41,7 +41,11 @@ impl VoxelGenerator { /// Generates a complete voxel chunk for the specified position. #[must_use] - #[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_wrap, + clippy::cast_possible_truncation, + reason = "chunk and voxel coordinates stay within the ranges these casts assume" + )] pub fn generate_chunk(&self, pos: ChunkPos) -> Chunk { let mut chunk = Chunk::default(); diff --git a/crates/shared/src/world/chunk.rs b/crates/shared/src/world/chunk.rs index 003b60c..fb4385d 100644 --- a/crates/shared/src/world/chunk.rs +++ b/crates/shared/src/world/chunk.rs @@ -89,7 +89,10 @@ impl PalettedChunk { let mut blocks = vec![BlockId::AIR; CHUNK_VOLUME].into_boxed_slice(); for (voxel, slot) in blocks.iter_mut().enumerate() { // A stored index was produced from a palette position, so it is always in range for `palette`. - #[expect(clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_truncation, + reason = "a packed index originates from a valid palette position and fits usize" + )] let index = Self::read_packed(&self.indices, voxel, self.bits_per_index) as usize; *slot = self.palette[index]; } @@ -158,7 +161,10 @@ mod tests { let mut chunk = Chunk::default(); for (i, block) in chunk.blocks.iter_mut().enumerate() { // `distinct` is a small test constant, so the modulo result always fits in a u16. - #[expect(clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_truncation, + reason = "distinct is a small test constant within u16 range" + )] let id = (i % distinct) as u16; *block = BlockId(id); } diff --git a/crates/shared/src/world/chunk_data.rs b/crates/shared/src/world/chunk_data.rs index 86a6b9e..6a23157 100644 --- a/crates/shared/src/world/chunk_data.rs +++ b/crates/shared/src/world/chunk_data.rs @@ -77,8 +77,10 @@ impl ChunkData { .enumerate() { if base != cur { - // `i` ranges over `0..CHUNK_VOLUME`, which fits comfortably in a `u32`. - #[expect(clippy::cast_possible_truncation)] + #[expect( + clippy::cast_possible_truncation, + reason = "i ranges over 0..CHUNK_VOLUME, which fits in u32" + )] data.set(i as u32, cur); } } diff --git a/crates/shared/src/world/coords.rs b/crates/shared/src/world/coords.rs index e526da7..fa00270 100644 --- a/crates/shared/src/world/coords.rs +++ b/crates/shared/src/world/coords.rs @@ -25,7 +25,11 @@ impl ChunkPos { /// Initializes a new chunk position from a world-space position measured in blocks. #[must_use] - #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "floored world coordinates stay within i32 range for supported world sizes" + )] pub fn from_world(x: f64, y: f64, z: f64) -> Self { ChunkPos { x: (x.floor() as i32).div_euclid(CHUNK_SIZE as i32), diff --git a/crates/shared/src/world/entity.rs b/crates/shared/src/world/entity.rs index 5839822..f9597b9 100644 --- a/crates/shared/src/world/entity.rs +++ b/crates/shared/src/world/entity.rs @@ -23,7 +23,11 @@ impl EntityPos { } /// Rebases the position so every component of `local` lies within `[0.0, CHUNK_SIZE)`, carrying any whole-chunk overflow into `chunk`. - #[expect(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + #[expect( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + reason = "chunk carry values are small and within f32/i32 exact range" + )] pub fn renormalize(&mut self) { let size = CHUNK_SIZE as f32; @@ -43,8 +47,10 @@ impl EntityPos { mod tests { use super::*; - // `CHUNK_SIZE` is 32, exactly representable, so the widening cannot lose precision here. - #[expect(clippy::cast_precision_loss)] + #[expect( + clippy::cast_precision_loss, + reason = "CHUNK_SIZE is 32, exactly representable as f32" + )] const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32; #[test] From aa9dc18efefd928fe8a9b72e16513a91fcd2b61d Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 14:23:30 +0200 Subject: [PATCH 12/21] feat(net): add QUIC endpoints with synvael ALPN and self-signed TLS --- crates/net/src/codec.rs | 9 +++ crates/net/src/endpoint.rs | 136 ++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index a715778..2ee541a 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -33,6 +33,15 @@ pub enum NetError { /// Reading an exact number of bytes from a quinn recv stream failed. #[error("quinn read error: {0}")] Read(#[from] quinn::ReadExactError), + /// Generation of the self-signed server certificate failed. + #[error("certificate generation error: {0}")] + Rcgen(#[from] rcgen::Error), + /// Construction of the `rustls` TLS configuration failed. + #[error("rustls configuration error: {0}")] + Rustls(#[from] rustls::Error), + /// The `rustls` configuration lacked a TLS 1.3 cipher suite, which QUIC requires. + #[error("no initial cipher suite for quic: {0}")] + NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite), } /// The maximum payload length, in bytes, accepted on the control stream (64 KiB), matching the mod-payload cap. Higher-bandwidth tiers such as chunk streaming define their own caps. diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs index 7b302ef..a1e684f 100644 --- a/crates/net/src/endpoint.rs +++ b/crates/net/src/endpoint.rs @@ -2,4 +2,138 @@ //! QUIC endpoint construction for client and server. //! -//! Builds the `quinn` endpoints and configures ALPN and TLS 1.3 (self-signed server certificate, permissive client verifier for the initial milestone). Populated in a later concept; currently a placeholder. +//! Builds the `quinn` endpoints and configures ALPN and TLS 1.3. Both endpoints negotiate the `synvael` application protocol; a peer advertising any other ALPN identifier is rejected during the TLS handshake. + +use std::net::SocketAddr; +use std::sync::Arc; + +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; +use quinn::{ClientConfig, Endpoint, ServerConfig}; +use rustls::DigitallySignedStruct; +use rustls::SignatureScheme; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime}; +use tracing::debug; + +use crate::codec::NetError; + +/// The Application-Layer Protocol Negotiation identifier for the Synvael protocol. +pub const ALPN: &[u8] = b"synvael"; + +/// Installs the process-wide default `rustls` `CryptoProvider` if one is not already installed. +fn ensure_crypto_provider() { + if rustls::crypto::ring::default_provider() + .install_default() + .is_err() + { + debug!("rustls crypto provider already installed; reusing existing default"); + } +} + +/// Builds a QUIC server endpoint bound to `bind`, using a freshly generated self-signed certificate and the `synvael` ALPN. +pub fn server_endpoint(bind: SocketAddr) -> Result { + ensure_crypto_provider(); + + // Generate a self-signed certificate for the "localhost" subject. The subject is not validated by the current client verifier and exists only to satisfy certificate structure. + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?; + let cert_der = cert.cert.der().clone(); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())); + + let mut tls_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der)?; + tls_config.alpn_protocols = vec![ALPN.to_vec()]; + + let quic_config = QuicServerConfig::try_from(tls_config)?; + let server_config = ServerConfig::with_crypto(Arc::new(quic_config)); + + Ok(Endpoint::server(server_config, bind)?) +} + +/// Builds a QUIC client endpoint bound to an ephemeral local address, configured with the `synvael` ALPN and a permissive certificate verifier. +pub fn client_endpoint() -> Result { + ensure_crypto_provider(); + + let mut tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert)) + .with_no_client_auth(); + tls_config.alpn_protocols = vec![ALPN.to_vec()]; + + let quic_config = QuicClientConfig::try_from(tls_config)?; + let client_config = ClientConfig::new(Arc::new(quic_config)); + + let mut endpoint = Endpoint::client("0.0.0.0:0".parse().map_err(std::io::Error::other)?)?; + endpoint.set_default_client_config(client_config); + + Ok(endpoint) +} + +/// A certificate verifier that unconditionally accepts any server certificate, disabling server authentication. +// TODO: replace with trust-on-first-use + certificate pinning. +#[derive(Debug)] +struct AcceptAnyServerCert; + +impl ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + // Advertise the schemes the installed provider can verify, so the shim does not artificially restrict handshake negotiation. + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alpn_identifier_is_synvael() { + assert_eq!(ALPN, b"synvael"); + } + + // A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction. + #[tokio::test] + async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> { + let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?; + let endpoint = server_endpoint(bind)?; + // A concrete port is assigned once the UDP socket is bound. + assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port"); + Ok(()) + } + + #[tokio::test] + async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> { + client_endpoint()?; + Ok(()) + } +} From c6d8d1bd70262950cf9245cdb62bb57317141d9e Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 19:56:19 +0200 Subject: [PATCH 13/21] refactor(net): move NetError into its own error module --- crates/net/src/codec.rs | 40 +-------------------------------- crates/net/src/endpoint.rs | 2 +- crates/net/src/error.rs | 45 ++++++++++++++++++++++++++++++++++++++ crates/net/src/lib.rs | 1 + 4 files changed, 48 insertions(+), 40 deletions(-) create mode 100644 crates/net/src/error.rs diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index 2ee541a..b38d8f3 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -4,45 +4,7 @@ //! //! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream. -/// Errors produced by the framing codec and its stream I/O helpers. -#[derive(Debug, thiserror::Error)] -pub enum NetError { - /// A `postcard` serialization or deserialization operation failed. - #[error("postcard codec error: {0}")] - Postcard(#[from] postcard::Error), - /// An underlying byte-stream I/O operation failed. - #[error("i/o error: {0}")] - Io(#[from] std::io::Error), - /// A declared frame length exceeded the caller-supplied maximum, indicating a malicious or corrupt peer. - #[error("frame length {len} exceeds maximum {max}")] - FrameTooLarge { - /// The frame length declared by the length prefix, in bytes. - len: u64, - /// The maximum payload length accepted by the reader, in bytes. - max: usize, - }, - /// The stream ended before a complete frame (prefix or payload) had been read. - #[error("unexpected end of stream while reading a frame")] - UnexpectedEof, - /// A varint length prefix was malformed: either overlong or otherwise invalid. - #[error("malformed varint length prefix")] - MalformedVarint, - /// Writing bytes to a quinn send stream failed. - #[error("quinn write error: {0}")] - Write(#[from] quinn::WriteError), - /// Reading an exact number of bytes from a quinn recv stream failed. - #[error("quinn read error: {0}")] - Read(#[from] quinn::ReadExactError), - /// Generation of the self-signed server certificate failed. - #[error("certificate generation error: {0}")] - Rcgen(#[from] rcgen::Error), - /// Construction of the `rustls` TLS configuration failed. - #[error("rustls configuration error: {0}")] - Rustls(#[from] rustls::Error), - /// The `rustls` configuration lacked a TLS 1.3 cipher suite, which QUIC requires. - #[error("no initial cipher suite for quic: {0}")] - NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite), -} +use crate::error::NetError; /// The maximum payload length, in bytes, accepted on the control stream (64 KiB), matching the mod-payload cap. Higher-bandwidth tiers such as chunk streaming define their own caps. pub const MAX_CONTROL_FRAME_LEN: usize = 64 * 1024; diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs index a1e684f..fe865e7 100644 --- a/crates/net/src/endpoint.rs +++ b/crates/net/src/endpoint.rs @@ -15,7 +15,7 @@ use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, Server use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime}; use tracing::debug; -use crate::codec::NetError; +use crate::error::NetError; /// The Application-Layer Protocol Negotiation identifier for the Synvael protocol. pub const ALPN: &[u8] = b"synvael"; diff --git a/crates/net/src/error.rs b/crates/net/src/error.rs new file mode 100644 index 0000000..00e2e4e --- /dev/null +++ b/crates/net/src/error.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Error types for the net crate. + +use thiserror::Error; + +/// Errors produced by the framing codec, endpoint construction, and stream I/O helpers. +#[derive(Debug, Error)] +pub enum NetError { + /// A `postcard` serialization or deserialization operation failed. + #[error("postcard codec error: {0}")] + Postcard(#[from] postcard::Error), + /// An underlying byte-stream I/O operation failed. + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + /// A declared frame length exceeded the caller-supplied maximum, indicating a malicious or corrupt peer. + #[error("frame length {len} exceeds maximum {max}")] + FrameTooLarge { + /// The frame length declared by the length prefix, in bytes. + len: u64, + /// The maximum payload length accepted by the reader, in bytes. + max: usize, + }, + /// The stream ended before a complete frame (prefix or payload) had been read. + #[error("unexpected end of stream while reading a frame")] + UnexpectedEof, + /// A varint length prefix was malformed: either overlong or otherwise invalid. + #[error("malformed varint length prefix")] + MalformedVarint, + /// Writing bytes to a quinn send stream failed. + #[error("quinn write error: {0}")] + Write(#[from] quinn::WriteError), + /// Reading an exact number of bytes from a quinn recv stream failed. + #[error("quinn read error: {0}")] + Read(#[from] quinn::ReadExactError), + /// Generation of the self-signed server certificate failed. + #[error("certificate generation error: {0}")] + Rcgen(#[from] rcgen::Error), + /// Construction of the `rustls` TLS configuration failed. + #[error("rustls configuration error: {0}")] + Rustls(#[from] rustls::Error), + /// The `rustls` configuration lacked a TLS 1.3 cipher suite, which QUIC requires. + #[error("no initial cipher suite for quic: {0}")] + NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite), +} diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 40cae1d..4cea45d 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -8,5 +8,6 @@ pub mod codec; pub mod endpoint; +pub mod error; pub mod handshake; pub mod runtime; From 092d546c52666fc241b79ef5ac81d64487a350d0 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 21:09:27 +0200 Subject: [PATCH 14/21] docs(workspace): standardize function doc sections and enforce # Errors --- AGENTS.md | 6 ++++++ Cargo.toml | 3 +-- crates/net/src/codec.rs | 20 ++++++++++++++++++++ crates/net/src/endpoint.rs | 8 ++++++++ crates/renderer/src/device.rs | 12 ++++++++++++ crates/renderer/src/instance.rs | 8 ++++++++ crates/renderer/src/lib.rs | 20 ++++++++++++++++++++ crates/renderer/src/pipeline.rs | 16 ++++++++++++++++ crates/renderer/src/renderer.rs | 15 ++++++++++++++- crates/renderer/src/surface.rs | 4 ++++ crates/renderer/src/swapchain.rs | 12 ++++++++++++ crates/renderer/src/sync.rs | 8 ++++++++ crates/server/src/save/region_actor.rs | 12 ++++++++++++ crates/server/src/save/region_file.rs | 24 ++++++++++++++++++++++++ crates/server/src/world_server.rs | 4 ++++ crates/shared/src/save/cursor.rs | 10 +++++++++- crates/shared/src/save/record.rs | 8 ++++++++ crates/shared/src/save/region.rs | 16 ++++++++++++++++ 18 files changed, 202 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a917554..d12d701 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,12 @@ The workspace opts into strict linting: Clippy's `pedantic` group plus restricti - **Voice:** Use the passive voice or neutral descriptive language. Instead of "We initialize the buffer," use "The buffer is initialized." Instead of "Your vertex shader needs this," use "The vertex shader requires this." - **Focus:** Describe the code's behavior, the system's state, or technical invariants. - **Struct Documentation:** Every field in a public or internal struct must have a doc comment (`///`) explaining its purpose and any invariants. +- **Function documentation sections:** Function doc comments follow the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/documentation.html) standard sections, in this fixed order after the summary and any extended description: `# Errors`, then `# Panics`, then `# Safety`. The sections apply to **all** functions, public and private (clippy only enforces the public ones; the same standard is expected on private helpers by hand). + - **`# Errors`** is mandatory on every function returning `Result`, and states the conditions under which each error variant is returned. `fn main` is exempt. + - **`# Panics`** is mandatory on any function that can panic (an `expect`/`unwrap`/`panic!`/`assert!`/indexing/arithmetic that can trip), and states the condition that triggers the panic. + - **`# Safety`** is mandatory on every `unsafe fn`, and states the invariants the caller must uphold. + - Test functions (`#[test]`, and helpers inside `#[cfg(test)]`) are exempt from all three; they are not part of the documented surface. + - Enforcement: `missing_errors_doc`, `missing_panics_doc`, and `missing_safety_doc` are warnings in the workspace lint set, so a missing section on a public item fails CI. - **Stability:** Treat the documentation as a technical specification for the engine. - **Line breaks:** Do not insert line returns inside a comment unless necessary. A comment that fits on a single line stays on a single line; do not pre-wrap at ~80 chars for aesthetics. Only break across lines when the comment is genuinely long (multi-sentence prose, enumerated invariants) or when a hard break carries meaning (separating an intro line from a bullet list, for instance). diff --git a/Cargo.toml b/Cargo.toml index 9690c18..e763305 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,5 +41,4 @@ unimplemented = "warn" # Pedantic exceptions (too noisy) module_name_repetitions = "allow" -must_use_candidate = "allow" -missing_errors_doc = "allow" \ No newline at end of file +must_use_candidate = "allow" \ No newline at end of file diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index b38d8f3..9270a0c 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -33,6 +33,10 @@ fn write_varint(value: u64, buf: &mut Vec) { } /// Reads an unsigned LEB128 varint from the front of `bytes`, returning the decoded value and the number of bytes consumed. +/// +/// # Errors +/// +/// Returns [`NetError::MalformedVarint`] if the encoding exceeds the ten bytes a `u64` may occupy, or [`NetError::UnexpectedEof`] if the buffer ends while the continuation bit is still set. fn read_varint(bytes: &[u8]) -> Result<(u64, usize), NetError> { let mut value: u64 = 0; let mut shift: u32 = 0; @@ -52,6 +56,10 @@ fn read_varint(bytes: &[u8]) -> Result<(u64, usize), NetError> { } /// Encodes `msg` as a single length-prefixed `postcard` frame into a freshly allocated buffer. +/// +/// # Errors +/// +/// Returns [`NetError::Postcard`] if `msg` fails to serialize. fn encode_frame(msg: &T) -> Result, NetError> { let payload = postcard::to_stdvec(msg)?; let mut frame = Vec::new(); @@ -61,6 +69,10 @@ fn encode_frame(msg: &T) -> Result, NetError> { } /// Writes one length-prefixed `postcard` frame to a quinn send stream. +/// +/// # Errors +/// +/// Returns [`NetError::Postcard`] if `msg` fails to serialize, or [`NetError::Write`] if the send stream rejects the bytes. pub async fn write_frame( stream: &mut quinn::SendStream, msg: &T, @@ -71,6 +83,10 @@ pub async fn write_frame( } /// Reads one length-prefixed `postcard` frame from a quinn recv stream and decodes it. +/// +/// # Errors +/// +/// Returns [`NetError::MalformedVarint`] if the length prefix is overlong, [`NetError::FrameTooLarge`] if the declared length exceeds `max_len`, [`NetError::Read`] if the stream ends before the frame is complete, or [`NetError::Postcard`] if the payload fails to deserialize. pub async fn read_frame( stream: &mut quinn::RecvStream, max_len: usize, @@ -98,6 +114,10 @@ pub async fn read_frame( } /// Validates a declared frame length against `max_len`. +/// +/// # Errors +/// +/// Returns [`NetError::FrameTooLarge`] if `len` exceeds `max_len`. fn check_frame_len(len: u64, max_len: usize) -> Result { if len > max_len as u64 { return Err(NetError::FrameTooLarge { len, max: max_len }); diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs index fe865e7..2acc0c6 100644 --- a/crates/net/src/endpoint.rs +++ b/crates/net/src/endpoint.rs @@ -31,6 +31,10 @@ fn ensure_crypto_provider() { } /// Builds a QUIC server endpoint bound to `bind`, using a freshly generated self-signed certificate and the `synvael` ALPN. +/// +/// # Errors +/// +/// Returns [`NetError::Rcgen`] if certificate generation fails, [`NetError::Rustls`] if the TLS configuration cannot be built, [`NetError::NoInitialCipherSuite`] if the configuration lacks a TLS 1.3 cipher suite, or [`NetError::Io`] if the UDP socket cannot be bound. pub fn server_endpoint(bind: SocketAddr) -> Result { ensure_crypto_provider(); @@ -51,6 +55,10 @@ pub fn server_endpoint(bind: SocketAddr) -> Result { } /// Builds a QUIC client endpoint bound to an ephemeral local address, configured with the `synvael` ALPN and a permissive certificate verifier. +/// +/// # Errors +/// +/// Returns [`NetError::NoInitialCipherSuite`] if the TLS configuration lacks a TLS 1.3 cipher suite, or [`NetError::Io`] if the local UDP socket cannot be bound. pub fn client_endpoint() -> Result { ensure_crypto_provider(); diff --git a/crates/renderer/src/device.rs b/crates/renderer/src/device.rs index a4d43d8..6fd461a 100644 --- a/crates/renderer/src/device.rs +++ b/crates/renderer/src/device.rs @@ -6,6 +6,10 @@ use crate::error::RendererError; use ash::{Device, Instance, khr, vk}; /// Picks a physical device (GPU) that supports the required features and extensions. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if physical devices cannot be enumerated, or [`RendererError::NoSuitableGpu`] if none meets the requirements. pub fn pick_physical_device( instance: &Instance, surface_loader: &khr::surface::Instance, @@ -27,6 +31,10 @@ pub fn pick_physical_device( } /// Creates a logical device and retrieves the graphics queue. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if the device cannot be created. pub fn create_logical_device( instance: &Instance, physical_device: vk::PhysicalDevice, @@ -58,6 +66,10 @@ pub fn create_logical_device( } /// Finds a queue family that supports both graphics commands and presentation. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if surface-support queries fail, or [`RendererError::NoSuitableGpu`] if no family supports both graphics and presentation. pub fn find_graphics_queue_family( instance: &Instance, physical_device: vk::PhysicalDevice, diff --git a/crates/renderer/src/instance.rs b/crates/renderer/src/instance.rs index 7a9a654..16bbd51 100644 --- a/crates/renderer/src/instance.rs +++ b/crates/renderer/src/instance.rs @@ -6,6 +6,10 @@ use std::ffi::{CStr, c_char}; use tracing::{debug, error, info, warn}; /// Creates a Vulkan instance and optionally a debug messenger. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if instance creation fails, or if the debug messenger cannot be created in debug builds. pub fn create_instance( entry: &Entry, required_extensions: &[*const c_char], @@ -62,6 +66,10 @@ pub fn create_instance( } /// The callback function invoked by Vulkan's validation layers. +/// +/// # Safety +/// +/// Invoked by the Vulkan loader, which must pass a valid `p_callback_data` pointer whose `p_message` is either null or a valid NUL-terminated C string. Not to be called directly. unsafe extern "system" fn vulkan_debug_callback( message_severity: vk::DebugUtilsMessageSeverityFlagsEXT, _message_type: vk::DebugUtilsMessageTypeFlagsEXT, diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index e9d5442..6bb5f49 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -36,6 +36,10 @@ impl Renderer { /// This function loads the Vulkan library, creates an instance, selects a GPU, /// and initializes a logical device with a graphics queue. /// + /// # Errors + /// + /// Returns [`RendererError`] if any initialization step fails: loading Vulkan, creating the instance, surface, device, swapchain, pipeline, allocator, or initial geometry. + /// /// # Panics /// /// Panics if `MAX_FRAMES_IN_FLIGHT` or vertex data sizes exceed `u32`/`u64` limits. @@ -166,6 +170,10 @@ impl Renderer { } /// Creates a GPU memory allocator. +/// +/// # Errors +/// +/// Returns [`RendererError::AllocationError`] if the allocator cannot be initialized. fn create_allocator( instance: &ash::Instance, device: &ash::Device, @@ -186,6 +194,10 @@ fn create_allocator( } /// Creates the 3D geometry buffers (vertex and index) for a cube. +/// +/// # Errors +/// +/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if a buffer cannot be created. fn create_geometry( device: &ash::Device, allocator: &mut Allocator, @@ -260,6 +272,10 @@ fn create_geometry( } /// Creates the depth buffer resources (image, memory, and view). +/// +/// # Errors +/// +/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the depth image or its view cannot be created. fn create_depth_resources( device: &ash::Device, allocator: &mut Allocator, @@ -321,6 +337,10 @@ fn create_depth_resources( } /// Helper function to create and populate a GPU buffer. +/// +/// # Errors +/// +/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the buffer cannot be created or bound. fn create_gpu_buffer( device: &ash::Device, allocator: &mut Allocator, diff --git a/crates/renderer/src/pipeline.rs b/crates/renderer/src/pipeline.rs index 4668ab5..0995dfe 100644 --- a/crates/renderer/src/pipeline.rs +++ b/crates/renderer/src/pipeline.rs @@ -10,6 +10,10 @@ use std::io::Cursor; /// Helper to load SPIR-V bytes and create a Vulkan Shader Module. /// /// Vulkan expects shader code to be 32-bit aligned; `ash::util::read_spv` is used to correctly interpret the raw bytes as a slice of `u32`. +/// +/// # Errors +/// +/// Returns [`RendererError::IoError`] if `bytes` is not valid, 32-bit-aligned SPIR-V, or [`RendererError::VulkanError`] if module creation fails on the device. pub fn create_shader_module( device: &Device, bytes: &[u8], @@ -27,6 +31,10 @@ pub fn create_shader_module( /// Defines the 'interface' of the pipeline (what data we can pass to the shaders). /// /// This layout defines any push constants or descriptor sets (textures/UBOs) accessed by the shaders during execution. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout. pub fn create_pipeline_layout(device: &Device) -> Result { // A single push constant range is defined for the MVP matrix, allowing it to be updated for every draw call with high efficiency. #[expect( @@ -50,6 +58,10 @@ pub fn create_pipeline_layout(device: &Device) -> Result Result<(vk::ShaderModule, vk::ShaderModule), RendererError> { diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index b8bf83e..ac5efee 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -79,6 +79,10 @@ pub struct Renderer { impl Renderer { /// Renders a single frame. + /// + /// # Errors + /// + /// Returns [`RendererError::SyncPrimitivesMissing`] if the synchronization primitives have been torn down, or [`RendererError::VulkanError`] if any device operation (fence wait, image acquire, command recording, submit, or present) fails. pub fn draw_frame(&mut self, camera_view: glam::Mat4) -> Result<(), RendererError> { let sync = self .sync @@ -157,6 +161,10 @@ impl Renderer { } /// Records the drawing commands into the given command buffer. + /// + /// # Errors + /// + /// Returns [`RendererError::VulkanError`] if beginning or ending command-buffer recording fails. fn record_commands( &self, cmd: vk::CommandBuffer, @@ -322,6 +330,10 @@ impl Renderer { } /// Transitions the swapchain image back to the presentation layout. + /// + /// # Errors + /// + /// Returns [`RendererError::VulkanError`] if the pipeline barrier command cannot be recorded. fn transition_to_present_layout( &self, cmd: vk::CommandBuffer, @@ -359,7 +371,8 @@ impl Renderer { /// Replaces the currently rendering mesh with a new set of vertices and indices. /// /// # Errors - /// Returns a `RendererError` if new Vulkan buffers cannot be allocated or created. + /// + /// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the vertex or index buffers cannot be created. #[expect( clippy::cast_possible_truncation, reason = "a chunk mesh's index count never approaches u32::MAX" diff --git a/crates/renderer/src/surface.rs b/crates/renderer/src/surface.rs index c904f53..94dc3a0 100644 --- a/crates/renderer/src/surface.rs +++ b/crates/renderer/src/surface.rs @@ -5,6 +5,10 @@ use ash::{Entry, Instance, khr, vk}; use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; /// Creates a Vulkan surface for the given window. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if the platform surface cannot be created for the given display and window handles. pub fn create_surface( entry: &Entry, instance: &Instance, diff --git a/crates/renderer/src/swapchain.rs b/crates/renderer/src/swapchain.rs index 718de42..159c272 100644 --- a/crates/renderer/src/swapchain.rs +++ b/crates/renderer/src/swapchain.rs @@ -4,6 +4,14 @@ use crate::error::RendererError; use ash::{Device, Instance, khr, vk}; /// Creates a swapchain and retrieves its images. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if a surface query fails or the swapchain and its images cannot be created. +/// +/// # Panics +/// +/// Panics if the driver reports zero surface formats, which the Vulkan specification forbids for a supported surface. pub fn create_swapchain( instance: &Instance, physical_device: vk::PhysicalDevice, @@ -87,6 +95,10 @@ pub fn create_swapchain( } /// Creates image views for the swapchain images. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if the device fails to create an image view. pub fn create_image_views( device: &Device, images: &[vk::Image], diff --git a/crates/renderer/src/sync.rs b/crates/renderer/src/sync.rs index d864f03..c9da30c 100644 --- a/crates/renderer/src/sync.rs +++ b/crates/renderer/src/sync.rs @@ -14,6 +14,10 @@ pub struct SyncPrimitives { } /// Creates all synchronization primitives for the given number of frames and images. +/// +/// # Errors +/// +/// Returns [`RendererError::VulkanError`] if the device fails to create a semaphore or fence. pub fn create_sync_primitives( device: &Device, max_frames_in_flight: usize, @@ -43,6 +47,10 @@ pub fn create_sync_primitives( } /// Destroys all synchronization primitives. +/// +/// # Safety +/// +/// The caller must ensure every primitive in `sync` was created from `device`, is no longer in use by any in-flight GPU work, and is not destroyed again. pub unsafe fn destroy_sync_primitives(device: &Device, sync: SyncPrimitives) { unsafe { for semaphore in sync.image_available { diff --git a/crates/server/src/save/region_actor.rs b/crates/server/src/save/region_actor.rs index 07430d7..0a96da7 100644 --- a/crates/server/src/save/region_actor.rs +++ b/crates/server/src/save/region_actor.rs @@ -108,6 +108,10 @@ fn actor_loop(region_dir: &Path, request_rx: &Receiver) { } /// Flushes every dirty region to disk, returning the first error while still attempting the rest. +/// +/// # Errors +/// +/// Returns the first [`SaveError`] produced by [`RegionFile::save`]; remaining dirty regions are still flushed. fn flush_dirty(regions: &mut HashMap<(i32, i32), RegionFile>) -> Result<(), SaveError> { let mut result = Ok(()); for region in regions.values_mut() { @@ -127,6 +131,10 @@ fn flush_dirty(regions: &mut HashMap<(i32, i32), RegionFile>) -> Result<(), Save } /// Returns the region file covering `pos`, opening and caching it on first access. +/// +/// # Errors +/// +/// Returns a [`SaveError`] from [`RegionFile::open`] if the region file exists but cannot be read or decoded. fn region_mut<'a>( regions: &'a mut HashMap<(i32, i32), RegionFile>, region_dir: &Path, @@ -143,6 +151,10 @@ fn region_mut<'a>( } /// Reads the stored chunk at `pos`, opening and caching its region file on first access. +/// +/// # Errors +/// +/// Returns a [`SaveError`] if the region file cannot be opened or the stored record cannot be decoded. fn read_chunk( regions: &mut HashMap<(i32, i32), RegionFile>, region_dir: &Path, diff --git a/crates/server/src/save/region_file.rs b/crates/server/src/save/region_file.rs index 72bec93..87b45a4 100644 --- a/crates/server/src/save/region_file.rs +++ b/crates/server/src/save/region_file.rs @@ -43,6 +43,10 @@ pub struct RegionFile { impl RegionFile { /// Opens the region file at `path`, or yields an empty region if the file does not yet exist. + /// + /// # Errors + /// + /// Returns [`SaveError::Io`] if the file cannot be read, a decoding error from [`RegionIndex::decode`] if the index is malformed, or [`SaveError::PayloadTooLarge`] / [`SaveError::Truncated`] if a header entry's span falls outside the file. pub fn open(path: PathBuf) -> Result { if !path.exists() { return Ok(Self { @@ -107,6 +111,10 @@ impl RegionFile { } /// Decodes and returns the chunk at `pos`, or `None` if the region holds no record for it. + /// + /// # Errors + /// + /// Returns a decoding error from [`record::decode`] if the stored record is malformed. pub fn read_chunk(&self, pos: ChunkPos) -> Result, SaveError> { match self.records.get(&pos) { Some(bytes) => { @@ -118,6 +126,10 @@ impl RegionFile { } /// Encodes `data` into a `SYNC` record stamped with `last_modified` and stores it under `pos`. + /// + /// # Errors + /// + /// Returns an encoding error from [`record::encode`] if serialization fails, or [`SaveError::PayloadTooLarge`] if the encoded record exceeds `u32::MAX` bytes. pub fn write_chunk( &mut self, pos: ChunkPos, @@ -150,6 +162,10 @@ impl RegionFile { } /// Flushes the region to disk with a crash-safe whole-file atomic rewrite, clearing the dirty flag. + /// + /// # Errors + /// + /// Returns [`SaveError::PayloadTooLarge`] if a record's length exceeds `u32::MAX`, or [`SaveError::Io`] if the atomic write to disk fails. pub fn save(&mut self) -> Result<(), SaveError> { let image = self.serialize()?; atomic_write(&self.path, &image)?; @@ -158,6 +174,10 @@ impl RegionFile { } /// Builds the complete on-disk file image: the encoded index followed by every record. + /// + /// # Errors + /// + /// Returns [`SaveError::PayloadTooLarge`] if the index or any record length exceeds `u32::MAX` bytes. // * NOTE: this is a whole-file rewrite. The right way to do it for large saves is to append changed records into free space and rewriting only the header table, so save cost scales with chunks modified rather than total file size. The free list and absolute offsets already on disk support that switch without a format change. // TODO: incremental save. fn serialize(&mut self) -> Result, SaveError> { @@ -188,6 +208,10 @@ impl RegionFile { } /// Writes `bytes` to `path` via the POSIX atomic-write pattern: `.tmp` + fsync + rename. +/// +/// # Errors +/// +/// Returns [`SaveError::Io`] if the parent directory cannot be created, or if writing, syncing, or renaming the temporary file fails. fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> { // The region directory is created on demand so the first write to a fresh world succeeds. if let Some(parent) = path.parent() { diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index 09a7aec..4cff871 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -230,6 +230,10 @@ fn load_chunk( } /// Sends a read request to the save actor and blocks for its reply, mapping a departed actor to an absent record so generation can still proceed. +/// +/// # Errors +/// +/// Returns the [`SaveError`] reported by the save actor if reading the stored chunk fails. A departed actor yields `Ok(None)` rather than an error. fn request_saved_chunk( save_tx: &Sender, pos: ChunkPos, diff --git a/crates/shared/src/save/cursor.rs b/crates/shared/src/save/cursor.rs index eb6d480..3fa5d35 100644 --- a/crates/shared/src/save/cursor.rs +++ b/crates/shared/src/save/cursor.rs @@ -17,7 +17,11 @@ impl<'a> Reader<'a> { Self { bytes, offset: 0 } } - /// Returns the next `n` bytes and advances the cursor, or [`SaveError::Truncated`] if fewer remain. + /// Returns the next `n` bytes and advances the cursor. + /// + /// # Errors + /// + /// Returns [`SaveError::Truncated`] if fewer than `n` bytes remain, or if the offset addition overflows. pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], SaveError> { let end = self.offset.checked_add(n).ok_or(SaveError::Truncated { offset: self.offset, @@ -37,6 +41,10 @@ impl<'a> Reader<'a> { } /// Returns the next `N` bytes as a fixed-size array and advances the cursor. + /// + /// # Errors + /// + /// Returns [`SaveError::Truncated`] if fewer than `N` bytes remain. pub(crate) fn take_array(&mut self) -> Result<[u8; N], SaveError> { let mut array = [0u8; N]; array.copy_from_slice(self.take(N)?); diff --git a/crates/shared/src/save/record.rs b/crates/shared/src/save/record.rs index 5d43e48..aa13e49 100644 --- a/crates/shared/src/save/record.rs +++ b/crates/shared/src/save/record.rs @@ -32,6 +32,10 @@ pub struct RecordMeta { } /// Encodes `data` into a `SYNC` record, stamping it with `last_modified` (unix-ms). +/// +/// # Errors +/// +/// Returns [`SaveError::Postcard`] if serialization fails, [`SaveError::Io`] if zstd compression fails, or [`SaveError::PayloadTooLarge`] if either the uncompressed or compressed length exceeds `u32::MAX`. pub fn encode(data: &ChunkData, last_modified: u64) -> Result, SaveError> { let uncompressed = postcard::to_stdvec(data)?; let compressed = zstd::encode_all(uncompressed.as_slice(), ZSTD_LEVEL)?; @@ -61,6 +65,10 @@ pub fn encode(data: &ChunkData, last_modified: u64) -> Result, SaveError /// /// `bytes` is untrusted on-disk input, so every field is bounds-checked and the decompressed /// payload length is validated against the header before deserialization is attempted. +/// +/// # Errors +/// +/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not `SYNC`, [`SaveError::Io`] if zstd decompression fails, [`SaveError::LengthMismatch`] if the decompressed length disagrees with the header, or [`SaveError::Postcard`] if the payload fails to deserialize. pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> { let mut reader = Reader::new(bytes); diff --git a/crates/shared/src/save/region.rs b/crates/shared/src/save/region.rs index ae7b78c..1c64fa6 100644 --- a/crates/shared/src/save/region.rs +++ b/crates/shared/src/save/region.rs @@ -134,6 +134,10 @@ impl RegionIndex { } /// Serializes the index to its on-disk framing bytes. + /// + /// # Errors + /// + /// Returns [`SaveError::PayloadTooLarge`] if the header, free-list, or stamp table holds more than `u32::MAX` entries. pub fn encode(&self) -> Result, SaveError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); @@ -171,6 +175,10 @@ impl RegionIndex { } /// Parses a region index from its framing bytes, ignoring any chunk records that follow it. + /// + /// # Errors + /// + /// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not the region magic, or [`SaveError::UnsupportedVersion`] if the format version is not recognised. pub fn decode(bytes: &[u8]) -> Result { let mut reader = Reader::new(bytes); @@ -232,6 +240,10 @@ impl RegionIndex { } /// Reads a chunk position as three little-endian `i32`s. +/// +/// # Errors +/// +/// Returns [`SaveError::Truncated`] if fewer than twelve bytes remain. fn read_pos(reader: &mut Reader) -> Result { let x = i32::from_le_bytes(reader.take_array()?); let y = i32::from_le_bytes(reader.take_array()?); @@ -240,6 +252,10 @@ fn read_pos(reader: &mut Reader) -> Result { } /// Narrows a table length to the `u32` the framing uses, failing loudly rather than truncating. +/// +/// # Errors +/// +/// Returns [`SaveError::PayloadTooLarge`] if `len` exceeds `u32::MAX`. fn len_u32(len: usize) -> Result { u32::try_from(len).map_err(|_| SaveError::PayloadTooLarge { len }) } From 8e7cd150854516e3d30eaac7d5af02c968923de8 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 13 Jul 2026 23:40:53 +0200 Subject: [PATCH 15/21] feat(net): perform synvael handshake and graceful disconnect --- crates/net/src/handshake.rs | 204 +++++++++++++++++++++++++++++++++- crates/net/tests/handshake.rs | 101 +++++++++++++++++ 2 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 crates/net/tests/handshake.rs diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs index 09fe275..cbc35b0 100644 --- a/crates/net/src/handshake.rs +++ b/crates/net/src/handshake.rs @@ -2,4 +2,206 @@ //! Synvael application handshake over an established QUIC connection. //! -//! Drives the `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange and protocol-version verification on the control stream. Populated in a later concept; currently a placeholder. +//! Drives the `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange and protocol-version verification on the control stream (stream 0). The QUIC/TLS handshake is completed by the transport layer before any of these functions run; the exchange here is the Synvael application handshake layered on top of it. +//! +//! The control stream `(SendStream, RecvStream)` is retained inside the returned handles ([`Connected`], [`ServerConnection`]) and is never finished after the handshake, so later concepts can reuse stream 0 for chat, commands, and disconnect. + +use quinn::{Connection, Incoming, RecvStream, SendStream, VarInt}; +use shared::protocol::{ + ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION, + RejectReason, StreamLayout, +}; +use thiserror::Error; +use tracing::{info, warn}; + +use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame}; +use crate::error::NetError; + +/// Application close code used when a peer is rejected during the handshake. +const CLOSE_CODE_REJECTED: u32 = 1; + +/// Application close code used for an orderly, graceful disconnect. +const CLOSE_CODE_GRACEFUL: u32 = 0; + +/// Upper bound on the wait for a rejected client to read the `HandshakeReject` and close, before the server tears the connection down anyway. +const REJECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Errors produced while performing the Synvael application handshake. +#[derive(Debug, Error)] +pub enum HandshakeError { + /// The synchronous `quinn` connect call failed before the connection attempt began. + #[error("quic connect error: {0}")] + Connect(#[from] quinn::ConnectError), + /// The QUIC connection failed to establish or was lost during the handshake. + #[error("quic connection error: {0}")] + Connection(#[from] quinn::ConnectionError), + /// A control-stream frame failed to encode, decode, or transfer. + #[error("control frame codec error: {0}")] + Codec(#[from] NetError), + /// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire. + #[error("handshake rejected: {0:?}")] + Rejected(HandshakeReject), + /// A control message other than the one expected for this handshake step arrived. + #[error("unexpected control message during handshake")] + UnexpectedMessage, + /// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`HandshakeReject`] to the client. + #[error("protocol version mismatch: client {client}, server {server}")] + VersionMismatch { + /// Protocol version advertised by the client in its `ClientHello`. + client: u32, + /// Protocol version the server was built against (`PROTOCOL_VERSION`). + server: u32, + }, +} + +/// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters. +#[derive(Debug)] +pub struct Connected { + /// The established QUIC connection. Additional streams are opened from it. + pub connection: Connection, + /// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake. + pub control: (SendStream, RecvStream), + /// The negotiated session parameters returned by the server. + pub ack: HandshakeAck, +} + +/// A completed server-side handshake for one connection: the established connection, the retained control stream, and the client's presented identity. +#[derive(Debug)] +pub struct ServerConnection { + /// The established QUIC connection. Additional streams are accepted from it. + pub connection: Connection, + /// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake. + pub control: (SendStream, RecvStream), + /// The `ClientHello` the accepted client presented. + pub hello: ClientHello, +} + +/// Connects to a Synvael server: establishes the QUIC connection, opens the control stream, sends `ClientHello`, and awaits the server's response. +/// +/// # Errors +/// +/// Returns [`HandshakeError::Connect`] if the connection attempt cannot be initiated, [`HandshakeError::Connection`] if the QUIC connection fails to establish, [`HandshakeError::Codec`] if the `ClientHello` cannot be written or the reply cannot be read, [`HandshakeError::Rejected`] if the server refuses the handshake, and [`HandshakeError::UnexpectedMessage`] if the server replies with a control message other than `HandshakeAck` or `HandshakeReject`. +pub async fn connect( + endpoint: &quinn::Endpoint, + server_addr: std::net::SocketAddr, + server_name: &str, + hello: ClientHello, +) -> Result { + let connection = endpoint.connect(server_addr, server_name)?.await?; + + // The client opens the control stream; the server accepts it. The stream first appears on the server once the `ClientHello` bytes are written below. + let (mut send, mut recv) = connection.open_bi().await?; + + write_frame(&mut send, &ControlMessage::ClientHello(hello)).await?; + + match read_frame::(&mut recv, MAX_CONTROL_FRAME_LEN).await? { + ControlMessage::HandshakeAck(ack) => { + info!( + protocol_version = ack.protocol_version, + server_build = %ack.server_build, + "handshake accepted by server" + ); + Ok(Connected { + connection, + control: (send, recv), + ack, + }) + } + ControlMessage::HandshakeReject(rej) => { + warn!(reason = ?rej.reason, detail = %rej.detail, "handshake rejected by server"); + Err(HandshakeError::Rejected(rej)) + } + _ => Err(HandshakeError::UnexpectedMessage), + } +} + +/// Accepts one incoming connection: completes the QUIC handshake, reads the client's `ClientHello`, validates it, and replies with `HandshakeAck` or `HandshakeReject`. +/// +/// On a protocol-version mismatch a [`HandshakeReject`] is sent to the client, the connection is closed with [`CLOSE_CODE_REJECTED`], and [`HandshakeError::VersionMismatch`] is returned locally. +/// +/// # Errors +/// +/// Returns [`HandshakeError::Connection`] if the QUIC connection fails to establish or the control stream cannot be accepted, [`HandshakeError::Codec`] if the `ClientHello` cannot be read or a reply cannot be written, [`HandshakeError::UnexpectedMessage`] if the first control message is not a `ClientHello`, and [`HandshakeError::VersionMismatch`] if the client's protocol version does not match the server's. +pub async fn accept_connection( + incoming: Incoming, + server_build: String, + tick_rate_hint: u16, +) -> Result { + let connection = incoming.await?; + + // The client opened the control stream; the server accepts it here. + let (mut send, mut recv) = connection.accept_bi().await?; + + let ControlMessage::ClientHello(hello) = + read_frame::(&mut recv, MAX_CONTROL_FRAME_LEN).await? + else { + return Err(HandshakeError::UnexpectedMessage); + }; + + if hello.protocol_version != PROTOCOL_VERSION { + let reject = HandshakeReject { + reason: RejectReason::ProtocolMismatch, + detail: format!( + "client protocol version {}, server protocol version {PROTOCOL_VERSION}", + hello.protocol_version + ), + upgrade_url: None, + }; + warn!( + client = hello.protocol_version, + server = PROTOCOL_VERSION, + "rejecting client on protocol mismatch" + ); + // Send the rejection, then keep the connection alive until the client has read it and closed. `Connection::close` (and dropping the connection) discards buffered stream data, so an immediate close would race the client's read and lose the reject frame. The wait is bounded so a misbehaving client cannot park the accept task indefinitely. + write_frame(&mut send, &ControlMessage::HandshakeReject(reject)).await?; + let _ = send.finish(); + let _ = tokio::time::timeout(REJECT_DELIVERY_TIMEOUT, connection.closed()).await; + connection.close( + VarInt::from_u32(CLOSE_CODE_REJECTED), + b"protocol version mismatch", + ); + return Err(HandshakeError::VersionMismatch { + client: hello.protocol_version, + server: PROTOCOL_VERSION, + }); + } + + // TODO: validate installed_packs against the world's required packs once the modlist-matching concept lands. M1 accepts any pack set and advertises none. + let ack = HandshakeAck { + protocol_version: PROTOCOL_VERSION, + server_build, + world_packs: vec![], + missing_packs: vec![], + stream_layout: StreamLayout::default(), + tick_rate_hint, + }; + write_frame(&mut send, &ControlMessage::HandshakeAck(ack)).await?; + + info!( + display_name = %hello.player_identity.display_name, + "handshake accepted" + ); + + Ok(ServerConnection { + connection, + control: (send, recv), + hello, + }) +} + +/// Sends a `Disconnect` control message and then closes the connection cleanly. +/// +/// The control stream's send half is borrowed mutably to write the final frame; the connection is then closed with [`CLOSE_CODE_GRACEFUL`]. A failure to write the `Disconnect` frame is logged rather than propagated, since the connection is closed unconditionally afterwards. Both [`Connected`] and [`ServerConnection`] expose their parts as `connection` and `control`, so either can call this with `(&conn.connection, &mut conn.control.0, reason)`. +pub async fn graceful_disconnect(connection: &Connection, send: &mut SendStream, reason: &str) { + if let Err(error) = write_frame( + send, + &ControlMessage::Disconnect(Disconnect { + reason: reason.to_owned(), + }), + ) + .await + { + warn!(%error, "failed to send disconnect frame; closing connection regardless"); + } + connection.close(VarInt::from_u32(CLOSE_CODE_GRACEFUL), reason.as_bytes()); +} diff --git a/crates/net/tests/handshake.rs b/crates/net/tests/handshake.rs new file mode 100644 index 0000000..866778c --- /dev/null +++ b/crates/net/tests/handshake.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Loopback integration tests for the Synvael application handshake. +//! +//! Each test binds a real QUIC server endpoint on `127.0.0.1:0`, reads the OS-assigned port, and drives a client through the full `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange, exercising the async `read_frame`/`write_frame` path end-to-end. + +use net::endpoint::{client_endpoint, server_endpoint}; +use net::handshake::{HandshakeError, accept_connection, connect}; +use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason}; + +/// Builds a `ClientHello` for `display_name` advertising `protocol_version`. +fn hello(display_name: &str, protocol_version: u32) -> ClientHello { + ClientHello { + protocol_version, + client_build: "synvael-client-test".to_owned(), + player_identity: PlayerIdentity { + display_name: display_name.to_owned(), + }, + installed_packs: vec![], + requested_features: FeatureFlags(0), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn happy_path_completes_handshake() -> Result<(), Box> { + let server = server_endpoint("127.0.0.1:0".parse()?)?; + let server_addr = server.local_addr()?; + + // Accept exactly one connection on the server, returning the observed identity. The connection is held open until the client closes it, so the ack frame is reliably delivered before teardown. + let server_task = tokio::spawn(async move { + let incoming = server.accept().await.ok_or("server endpoint closed")?; + let conn = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await?; + let name = conn.hello.player_identity.display_name.clone(); + conn.connection.closed().await; + Ok::<_, Box>(name) + }); + + let client = client_endpoint()?; + let connected = connect( + &client, + server_addr, + "localhost", + hello("Tester", PROTOCOL_VERSION), + ) + .await?; + + assert_eq!( + connected.ack.protocol_version, PROTOCOL_VERSION, + "server must ack with the matching protocol version" + ); + + // Close the client connection so the server's `closed()` wait resolves. + drop(connected); + let observed_name = server_task.await??; + assert_eq!( + observed_name, "Tester", + "server must observe the client's display name" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn version_mismatch_is_rejected() -> Result<(), Box> { + let server = server_endpoint("127.0.0.1:0".parse()?)?; + let server_addr = server.local_addr()?; + + let server_task = tokio::spawn(async move { + let incoming = server.accept().await.ok_or("server endpoint closed")?; + // The server is expected to return VersionMismatch after sending the reject. + let result = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await; + Ok::<_, Box>(result.is_err()) + }); + + let client = client_endpoint()?; + let result = connect( + &client, + server_addr, + "localhost", + hello("Tester", PROTOCOL_VERSION + 1), + ) + .await; + + match result { + Err(HandshakeError::Rejected(rej)) => { + assert_eq!( + rej.reason, + RejectReason::ProtocolMismatch, + "rejection must cite a protocol mismatch" + ); + } + other => return Err(format!("expected a rejection, got {other:?}").into()), + } + + assert!( + server_task.await??, + "server must return an error on mismatch" + ); + + Ok(()) +} From 7b3908dd7f8a5d1e7b5a8427afed6083048696ad Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 14 Jul 2026 00:16:15 +0200 Subject: [PATCH 16/21] test(workspace): relocate all tests into per-crate src/tests via #[path] --- crates/net/src/codec.rs | 120 +------------ crates/net/src/endpoint.rs | 26 +-- crates/net/src/error.rs | 28 +++ crates/net/src/handshake.rs | 31 +--- crates/net/src/lib.rs | 4 + crates/net/src/tests/codec.rs | 118 +++++++++++++ crates/net/src/tests/endpoint.rs | 24 +++ crates/net/{ => src}/tests/handshake.rs | 5 +- crates/scripting/src/lib.rs | 11 +- crates/scripting/src/tests/lib.rs | 9 + crates/server/src/chunk_cache.rs | 52 +----- crates/server/src/save/region_file.rs | 138 +-------------- crates/server/src/tests/chunk_cache.rs | 50 ++++++ crates/server/src/tests/region_file.rs | 136 +++++++++++++++ crates/server/src/tests/world_server.rs | 213 +++++++++++++++++++++++ crates/server/src/world_server.rs | 215 +----------------------- crates/shared/src/protocol.rs | 89 +--------- crates/shared/src/save/record.rs | 72 +------- crates/shared/src/save/region.rs | 96 +---------- crates/shared/src/tests/chunk.rs | 69 ++++++++ crates/shared/src/tests/chunk_data.rs | 65 +++++++ crates/shared/src/tests/coords.rs | 25 +++ crates/shared/src/tests/entity.rs | 50 ++++++ crates/shared/src/tests/protocol.rs | 87 ++++++++++ crates/shared/src/tests/record.rs | 70 ++++++++ crates/shared/src/tests/region.rs | 94 +++++++++++ crates/shared/src/world/chunk.rs | 71 +------- crates/shared/src/world/chunk_data.rs | 67 +------- crates/shared/src/world/coords.rs | 27 +-- crates/shared/src/world/entity.rs | 52 +----- 30 files changed, 1072 insertions(+), 1042 deletions(-) create mode 100644 crates/net/src/tests/codec.rs create mode 100644 crates/net/src/tests/endpoint.rs rename crates/net/{ => src}/tests/handshake.rs (96%) create mode 100644 crates/scripting/src/tests/lib.rs create mode 100644 crates/server/src/tests/chunk_cache.rs create mode 100644 crates/server/src/tests/region_file.rs create mode 100644 crates/server/src/tests/world_server.rs create mode 100644 crates/shared/src/tests/chunk.rs create mode 100644 crates/shared/src/tests/chunk_data.rs create mode 100644 crates/shared/src/tests/coords.rs create mode 100644 crates/shared/src/tests/entity.rs create mode 100644 crates/shared/src/tests/protocol.rs create mode 100644 crates/shared/src/tests/record.rs create mode 100644 crates/shared/src/tests/region.rs diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index 9270a0c..fc26464 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -130,121 +130,5 @@ fn check_frame_len(len: u64, max_len: usize) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use shared::protocol::{ - ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, - }; - - /// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed. - #[test] - fn varint_round_trip_boundaries() -> Result<(), NetError> { - let cases = [ - 0, - 1, - 127, - 128, - 16_383, - 16_384, - u64::from(u32::MAX), - u64::MAX, - ]; - for value in cases { - let mut buf = Vec::new(); - write_varint(value, &mut buf); - let (decoded, consumed) = read_varint(&buf)?; - assert_eq!(decoded, value, "decoded value mismatch"); - assert_eq!( - consumed, - buf.len(), - "consumed length must equal encoded length" - ); - } - Ok(()) - } - - /// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count. - #[test] - fn encode_frame_prefixes_payload_length() -> Result<(), NetError> { - let msg = ControlMessage::ClientHello(ClientHello { - protocol_version: PROTOCOL_VERSION, - client_build: "synvael-client-0.1.0".to_string(), - player_identity: PlayerIdentity { - display_name: "Player1".to_string(), - }, - installed_packs: Vec::new(), - requested_features: FeatureFlags(0), - }); - let payload = postcard::to_stdvec(&msg)?; - let frame = encode_frame(&msg)?; - - let (declared_len, prefix_bytes) = read_varint(&frame)?; - assert_eq!( - declared_len, - payload.len() as u64, - "prefix must equal payload length" - ); - assert_eq!( - &frame[prefix_bytes..], - payload.as_slice(), - "payload bytes must follow the prefix unchanged" - ); - Ok(()) - } - - /// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly. - #[test] - fn varint_boundary_lengths() { - let mut buf = Vec::new(); - write_varint(127, &mut buf); - assert_eq!(buf.len(), 1, "127 must encode in a single byte"); - - buf.clear(); - write_varint(128, &mut buf); - assert_eq!(buf.len(), 2, "128 must encode in two bytes"); - } - - /// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic. - #[test] - fn varint_truncated_is_error() { - // Two bytes both flagged as "continued", with no terminating byte. - let truncated = [0x80u8, 0x80u8]; - assert!( - read_varint(&truncated).is_err(), - "truncated varint must return an error" - ); - } - - /// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted. - #[test] - fn varint_overlong_is_error() { - // Eleven continuation bytes followed by a terminator exceeds the u64 limit. - let overlong = [0x80u8; 11]; - assert!( - read_varint(&overlong).is_err(), - "overlong varint must return an error" - ); - } - - /// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation. - #[test] - fn frame_len_bound_is_enforced() { - assert_eq!( - check_frame_len(64, 128).ok(), - Some(64), - "a length within the cap is accepted" - ); - assert_eq!( - check_frame_len(128, 128).ok(), - Some(128), - "a length equal to the cap is accepted" - ); - assert!( - matches!( - check_frame_len(129, 128), - Err(NetError::FrameTooLarge { len: 129, max: 128 }) - ), - "a length over the cap must be rejected", - ); - } -} +#[path = "tests/codec.rs"] +mod tests; diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs index 2acc0c6..2ef792b 100644 --- a/crates/net/src/endpoint.rs +++ b/crates/net/src/endpoint.rs @@ -121,27 +121,5 @@ impl ServerCertVerifier for AcceptAnyServerCert { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn alpn_identifier_is_synvael() { - assert_eq!(ALPN, b"synvael"); - } - - // A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction. - #[tokio::test] - async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> { - let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?; - let endpoint = server_endpoint(bind)?; - // A concrete port is assigned once the UDP socket is bound. - assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port"); - Ok(()) - } - - #[tokio::test] - async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> { - client_endpoint()?; - Ok(()) - } -} +#[path = "tests/endpoint.rs"] +mod tests; diff --git a/crates/net/src/error.rs b/crates/net/src/error.rs index 00e2e4e..0af78cc 100644 --- a/crates/net/src/error.rs +++ b/crates/net/src/error.rs @@ -43,3 +43,31 @@ pub enum NetError { #[error("no initial cipher suite for quic: {0}")] NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite), } + +/// Errors produced while performing the Synvael application handshake. +#[derive(Debug, Error)] +pub enum HandshakeError { + /// The synchronous `quinn` connect call failed before the connection attempt began. + #[error("quic connect error: {0}")] + Connect(#[from] quinn::ConnectError), + /// The QUIC connection failed to establish or was lost during the handshake. + #[error("quic connection error: {0}")] + Connection(#[from] quinn::ConnectionError), + /// A control-stream frame failed to encode, decode, or transfer. + #[error("control frame codec error: {0}")] + Codec(#[from] NetError), + /// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire. + #[error("handshake rejected: {0:?}")] + Rejected(shared::protocol::HandshakeReject), + /// A control message other than the one expected for this handshake step arrived. + #[error("unexpected control message during handshake")] + UnexpectedMessage, + /// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`shared::protocol::HandshakeReject`] to the client. + #[error("protocol version mismatch: client {client}, server {server}")] + VersionMismatch { + /// Protocol version advertised by the client in its `ClientHello`. + client: u32, + /// Protocol version the server was built against (`shared::protocol::PROTOCOL_VERSION`). + server: u32, + }, +} diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs index cbc35b0..5f871fb 100644 --- a/crates/net/src/handshake.rs +++ b/crates/net/src/handshake.rs @@ -11,11 +11,10 @@ use shared::protocol::{ ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION, RejectReason, StreamLayout, }; -use thiserror::Error; use tracing::{info, warn}; use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame}; -use crate::error::NetError; +use crate::error::HandshakeError; /// Application close code used when a peer is rejected during the handshake. const CLOSE_CODE_REJECTED: u32 = 1; @@ -26,34 +25,6 @@ const CLOSE_CODE_GRACEFUL: u32 = 0; /// Upper bound on the wait for a rejected client to read the `HandshakeReject` and close, before the server tears the connection down anyway. const REJECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -/// Errors produced while performing the Synvael application handshake. -#[derive(Debug, Error)] -pub enum HandshakeError { - /// The synchronous `quinn` connect call failed before the connection attempt began. - #[error("quic connect error: {0}")] - Connect(#[from] quinn::ConnectError), - /// The QUIC connection failed to establish or was lost during the handshake. - #[error("quic connection error: {0}")] - Connection(#[from] quinn::ConnectionError), - /// A control-stream frame failed to encode, decode, or transfer. - #[error("control frame codec error: {0}")] - Codec(#[from] NetError), - /// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire. - #[error("handshake rejected: {0:?}")] - Rejected(HandshakeReject), - /// A control message other than the one expected for this handshake step arrived. - #[error("unexpected control message during handshake")] - UnexpectedMessage, - /// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`HandshakeReject`] to the client. - #[error("protocol version mismatch: client {client}, server {server}")] - VersionMismatch { - /// Protocol version advertised by the client in its `ClientHello`. - client: u32, - /// Protocol version the server was built against (`PROTOCOL_VERSION`). - server: u32, - }, -} - /// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters. #[derive(Debug)] pub struct Connected { diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 4cea45d..594e421 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -11,3 +11,7 @@ pub mod endpoint; pub mod error; pub mod handshake; pub mod runtime; + +#[cfg(test)] +#[path = "tests/handshake.rs"] +mod handshake_tests; diff --git a/crates/net/src/tests/codec.rs b/crates/net/src/tests/codec.rs new file mode 100644 index 0000000..1d31fb7 --- /dev/null +++ b/crates/net/src/tests/codec.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; +use shared::protocol::{ + ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, +}; + +/// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed. +#[test] +fn varint_round_trip_boundaries() -> Result<(), NetError> { + let cases = [ + 0, + 1, + 127, + 128, + 16_383, + 16_384, + u64::from(u32::MAX), + u64::MAX, + ]; + for value in cases { + let mut buf = Vec::new(); + write_varint(value, &mut buf); + let (decoded, consumed) = read_varint(&buf)?; + assert_eq!(decoded, value, "decoded value mismatch"); + assert_eq!( + consumed, + buf.len(), + "consumed length must equal encoded length" + ); + } + Ok(()) +} + +/// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count. +#[test] +fn encode_frame_prefixes_payload_length() -> Result<(), NetError> { + let msg = ControlMessage::ClientHello(ClientHello { + protocol_version: PROTOCOL_VERSION, + client_build: "synvael-client-0.1.0".to_string(), + player_identity: PlayerIdentity { + display_name: "Player1".to_string(), + }, + installed_packs: Vec::new(), + requested_features: FeatureFlags(0), + }); + let payload = postcard::to_stdvec(&msg)?; + let frame = encode_frame(&msg)?; + + let (declared_len, prefix_bytes) = read_varint(&frame)?; + assert_eq!( + declared_len, + payload.len() as u64, + "prefix must equal payload length" + ); + assert_eq!( + &frame[prefix_bytes..], + payload.as_slice(), + "payload bytes must follow the prefix unchanged" + ); + Ok(()) +} + +/// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly. +#[test] +fn varint_boundary_lengths() { + let mut buf = Vec::new(); + write_varint(127, &mut buf); + assert_eq!(buf.len(), 1, "127 must encode in a single byte"); + + buf.clear(); + write_varint(128, &mut buf); + assert_eq!(buf.len(), 2, "128 must encode in two bytes"); +} + +/// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic. +#[test] +fn varint_truncated_is_error() { + // Two bytes both flagged as "continued", with no terminating byte. + let truncated = [0x80u8, 0x80u8]; + assert!( + read_varint(&truncated).is_err(), + "truncated varint must return an error" + ); +} + +/// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted. +#[test] +fn varint_overlong_is_error() { + // Eleven continuation bytes followed by a terminator exceeds the u64 limit. + let overlong = [0x80u8; 11]; + assert!( + read_varint(&overlong).is_err(), + "overlong varint must return an error" + ); +} + +/// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation. +#[test] +fn frame_len_bound_is_enforced() { + assert_eq!( + check_frame_len(64, 128).ok(), + Some(64), + "a length within the cap is accepted" + ); + assert_eq!( + check_frame_len(128, 128).ok(), + Some(128), + "a length equal to the cap is accepted" + ); + assert!( + matches!( + check_frame_len(129, 128), + Err(NetError::FrameTooLarge { len: 129, max: 128 }) + ), + "a length over the cap must be rejected", + ); +} diff --git a/crates/net/src/tests/endpoint.rs b/crates/net/src/tests/endpoint.rs new file mode 100644 index 0000000..0f50b09 --- /dev/null +++ b/crates/net/src/tests/endpoint.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +#[test] +fn alpn_identifier_is_synvael() { + assert_eq!(ALPN, b"synvael"); +} + +// A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction. +#[tokio::test] +async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> { + let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?; + let endpoint = server_endpoint(bind)?; + // A concrete port is assigned once the UDP socket is bound. + assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port"); + Ok(()) +} + +#[tokio::test] +async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> { + client_endpoint()?; + Ok(()) +} diff --git a/crates/net/tests/handshake.rs b/crates/net/src/tests/handshake.rs similarity index 96% rename from crates/net/tests/handshake.rs rename to crates/net/src/tests/handshake.rs index 866778c..b4f10aa 100644 --- a/crates/net/tests/handshake.rs +++ b/crates/net/src/tests/handshake.rs @@ -4,8 +4,9 @@ //! //! Each test binds a real QUIC server endpoint on `127.0.0.1:0`, reads the OS-assigned port, and drives a client through the full `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange, exercising the async `read_frame`/`write_frame` path end-to-end. -use net::endpoint::{client_endpoint, server_endpoint}; -use net::handshake::{HandshakeError, accept_connection, connect}; +use crate::endpoint::{client_endpoint, server_endpoint}; +use crate::error::HandshakeError; +use crate::handshake::{accept_connection, connect}; use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason}; /// Builds a `ClientHello` for `display_name` advertising `protocol_version`. diff --git a/crates/scripting/src/lib.rs b/crates/scripting/src/lib.rs index 16e87cc..7114121 100644 --- a/crates/scripting/src/lib.rs +++ b/crates/scripting/src/lib.rs @@ -13,12 +13,5 @@ pub fn add(left: u64, right: u64) -> u64 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +#[path = "tests/lib.rs"] +mod tests; diff --git a/crates/scripting/src/tests/lib.rs b/crates/scripting/src/tests/lib.rs new file mode 100644 index 0000000..9aadc1b --- /dev/null +++ b/crates/scripting/src/tests/lib.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +#[test] +fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); +} diff --git a/crates/server/src/chunk_cache.rs b/crates/server/src/chunk_cache.rs index a4b191a..89d752e 100644 --- a/crates/server/src/chunk_cache.rs +++ b/crates/server/src/chunk_cache.rs @@ -62,53 +62,5 @@ impl ChunkCache { } #[cfg(test)] -mod tests { - use super::*; - use shared::generator::{VoxelGenerator, WorldGenConfig}; - use shared::world::{BlockId, ChunkPos}; - use std::num::NonZeroUsize; - - /// Builds a generator with a small, cheap terrain configuration for cache tests. - fn test_generator() -> VoxelGenerator { - let config = WorldGenConfig { - base_height: 8, - noise_scale: 0.05, - surface_block: BlockId(1), - subsurface_block: BlockId(2), - stone_block: BlockId(3), - }; - VoxelGenerator::new(config, 42) - } - - /// A second lookup of the same position must be served from the store, not regenerated. - #[test] - fn repeated_lookup_is_a_cache_hit() { - let generator = test_generator(); - let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN)); - let pos = ChunkPos::new(0, 0, 0); - - let first = cache.get_or_generate(pos, &generator); - let second = cache.get_or_generate(pos, &generator); - - // Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value. - assert_eq!(first.blocks, second.blocks); - assert_eq!(cache.len(), 1); - } - - /// Inserting beyond capacity evicts the least-recently-used entry. - #[test] - fn exceeding_capacity_evicts_oldest() { - let generator = test_generator(); - let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN)); - - let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator); - let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator); - // Touch the first so the second becomes the least-recently-used before the overflow. - let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator); - let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator); - - assert_eq!(cache.len(), 2); - assert!(cache.contains(ChunkPos::new(0, 0, 0))); - assert!(!cache.contains(ChunkPos::new(1, 0, 0))); - } -} +#[path = "tests/chunk_cache.rs"] +mod tests; diff --git a/crates/server/src/save/region_file.rs b/crates/server/src/save/region_file.rs index 87b45a4..49ab57d 100644 --- a/crates/server/src/save/region_file.rs +++ b/crates/server/src/save/region_file.rs @@ -241,139 +241,5 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> { } #[cfg(test)] -mod tests { - use super::*; - use shared::world::BlockId; - - /// Builds a representative modified chunk with a few edits spanning the local index range. - fn sample(pos: ChunkPos) -> ChunkData { - let mut data = ChunkData::new(pos, 7); - data.set(0, BlockId(4)); - data.set(1000, BlockId(9)); - data.set(32_767, BlockId(2)); - data - } - - #[test] - fn region_coords_floor_negative_columns() { - // Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. - assert_eq!(region_coords(0, 0), (0, 0)); - assert_eq!(region_coords(31, 31), (0, 0)); - assert_eq!(region_coords(-1, -1), (-1, -1)); - assert_eq!(region_coords(-32, -33), (-1, -2)); - } - - #[test] - fn region_path_names_the_region_file() { - let dir = Path::new("/saves/world/region"); - assert_eq!( - region_path(dir, -1, 5), - Path::new("/saves/world/region/r.-1.0.region") - ); - } - - #[test] - fn open_missing_file_is_empty() -> Result<(), SaveError> { - let dir = tempfile::tempdir()?; - let region = RegionFile::open(dir.path().join("r.0.0.region"))?; - assert!(region.is_empty()); - assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None); - Ok(()) - } - - #[test] - fn round_trips_chunks_through_disk() -> Result<(), SaveError> { - let dir = tempfile::tempdir()?; - let path = dir.path().join("r.0.0.region"); - - let positions = [ - ChunkPos::new(0, 0, 0), - ChunkPos::new(1, 2, 3), - ChunkPos::new(-5, 10, -30), - ]; - - let mut region = RegionFile::open(path.clone())?; - for pos in positions { - region.write_chunk(pos, &sample(pos), 123)?; - } - assert!(region.is_dirty()); - region.save()?; - assert!(!region.is_dirty()); - - // Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically. - let reopened = RegionFile::open(path)?; - assert_eq!(reopened.len(), positions.len()); - for pos in positions { - assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); - } - // A position never written has no record. - assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None); - Ok(()) - } - - #[test] - fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> { - let dir = tempfile::tempdir()?; - let path = dir.path().join("r.0.0.region"); - - let mut region = RegionFile::open(path.clone())?; - for pos in [ - ChunkPos::new(0, 0, 0), - ChunkPos::new(2, 0, 1), - ChunkPos::new(-1, 4, -1), - ] { - region.write_chunk(pos, &sample(pos), 0)?; - } - region.save()?; - - let reopened = RegionFile::open(path)?; - let index_len = reopened.index.encode()?.len() as u64; - - // Records are packed contiguously immediately after the index, in ascending position order. - let mut expected_offset = index_len; - for (_pos, entry) in reopened.index.entries() { - assert_eq!(entry.offset, expected_offset); - expected_offset += u64::from(entry.length); - } - Ok(()) - } - - #[test] - fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> { - let dir = tempfile::tempdir()?; - let path = dir.path().join("r.0.0.region"); - let kept = ChunkPos::new(0, 0, 0); - let dropped = ChunkPos::new(1, 1, 1); - - let mut region = RegionFile::open(path.clone())?; - region.write_chunk(kept, &sample(kept), 0)?; - region.write_chunk(dropped, &sample(dropped), 0)?; - region.save()?; - - let mut region = RegionFile::open(path.clone())?; - region.remove_chunk(dropped); - region.save()?; - - let reopened = RegionFile::open(path)?; - assert_eq!(reopened.read_chunk(dropped)?, None); - assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept))); - Ok(()) - } - - #[test] - fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> { - let dir = tempfile::tempdir()?; - let path = dir.path().join("r.0.0.region"); - let pos = ChunkPos::new(0, 0, 0); - - let mut region = RegionFile::open(path.clone())?; - region.write_chunk(pos, &sample(pos), 0)?; - region.save()?; - - // A leftover .tmp from an interrupted save must be ignored: only the renamed target is read. - fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?; - let reopened = RegionFile::open(path)?; - assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); - Ok(()) - } -} +#[path = "../tests/region_file.rs"] +mod tests; diff --git a/crates/server/src/tests/chunk_cache.rs b/crates/server/src/tests/chunk_cache.rs new file mode 100644 index 0000000..f07f796 --- /dev/null +++ b/crates/server/src/tests/chunk_cache.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; +use shared::generator::{VoxelGenerator, WorldGenConfig}; +use shared::world::{BlockId, ChunkPos}; +use std::num::NonZeroUsize; + +/// Builds a generator with a small, cheap terrain configuration for cache tests. +fn test_generator() -> VoxelGenerator { + let config = WorldGenConfig { + base_height: 8, + noise_scale: 0.05, + surface_block: BlockId(1), + subsurface_block: BlockId(2), + stone_block: BlockId(3), + }; + VoxelGenerator::new(config, 42) +} + +/// A second lookup of the same position must be served from the store, not regenerated. +#[test] +fn repeated_lookup_is_a_cache_hit() { + let generator = test_generator(); + let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN)); + let pos = ChunkPos::new(0, 0, 0); + + let first = cache.get_or_generate(pos, &generator); + let second = cache.get_or_generate(pos, &generator); + + // Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value. + assert_eq!(first.blocks, second.blocks); + assert_eq!(cache.len(), 1); +} + +/// Inserting beyond capacity evicts the least-recently-used entry. +#[test] +fn exceeding_capacity_evicts_oldest() { + let generator = test_generator(); + let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN)); + + let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator); + let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator); + // Touch the first so the second becomes the least-recently-used before the overflow. + let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator); + let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator); + + assert_eq!(cache.len(), 2); + assert!(cache.contains(ChunkPos::new(0, 0, 0))); + assert!(!cache.contains(ChunkPos::new(1, 0, 0))); +} diff --git a/crates/server/src/tests/region_file.rs b/crates/server/src/tests/region_file.rs new file mode 100644 index 0000000..439cfb7 --- /dev/null +++ b/crates/server/src/tests/region_file.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; +use shared::world::BlockId; + +/// Builds a representative modified chunk with a few edits spanning the local index range. +fn sample(pos: ChunkPos) -> ChunkData { + let mut data = ChunkData::new(pos, 7); + data.set(0, BlockId(4)); + data.set(1000, BlockId(9)); + data.set(32_767, BlockId(2)); + data +} + +#[test] +fn region_coords_floor_negative_columns() { + // Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. + assert_eq!(region_coords(0, 0), (0, 0)); + assert_eq!(region_coords(31, 31), (0, 0)); + assert_eq!(region_coords(-1, -1), (-1, -1)); + assert_eq!(region_coords(-32, -33), (-1, -2)); +} + +#[test] +fn region_path_names_the_region_file() { + let dir = Path::new("/saves/world/region"); + assert_eq!( + region_path(dir, -1, 5), + Path::new("/saves/world/region/r.-1.0.region") + ); +} + +#[test] +fn open_missing_file_is_empty() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let region = RegionFile::open(dir.path().join("r.0.0.region"))?; + assert!(region.is_empty()); + assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None); + Ok(()) +} + +#[test] +fn round_trips_chunks_through_disk() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + + let positions = [ + ChunkPos::new(0, 0, 0), + ChunkPos::new(1, 2, 3), + ChunkPos::new(-5, 10, -30), + ]; + + let mut region = RegionFile::open(path.clone())?; + for pos in positions { + region.write_chunk(pos, &sample(pos), 123)?; + } + assert!(region.is_dirty()); + region.save()?; + assert!(!region.is_dirty()); + + // Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically. + let reopened = RegionFile::open(path)?; + assert_eq!(reopened.len(), positions.len()); + for pos in positions { + assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); + } + // A position never written has no record. + assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None); + Ok(()) +} + +#[test] +fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + + let mut region = RegionFile::open(path.clone())?; + for pos in [ + ChunkPos::new(0, 0, 0), + ChunkPos::new(2, 0, 1), + ChunkPos::new(-1, 4, -1), + ] { + region.write_chunk(pos, &sample(pos), 0)?; + } + region.save()?; + + let reopened = RegionFile::open(path)?; + let index_len = reopened.index.encode()?.len() as u64; + + // Records are packed contiguously immediately after the index, in ascending position order. + let mut expected_offset = index_len; + for (_pos, entry) in reopened.index.entries() { + assert_eq!(entry.offset, expected_offset); + expected_offset += u64::from(entry.length); + } + Ok(()) +} + +#[test] +fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + let kept = ChunkPos::new(0, 0, 0); + let dropped = ChunkPos::new(1, 1, 1); + + let mut region = RegionFile::open(path.clone())?; + region.write_chunk(kept, &sample(kept), 0)?; + region.write_chunk(dropped, &sample(dropped), 0)?; + region.save()?; + + let mut region = RegionFile::open(path.clone())?; + region.remove_chunk(dropped); + region.save()?; + + let reopened = RegionFile::open(path)?; + assert_eq!(reopened.read_chunk(dropped)?, None); + assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept))); + Ok(()) +} + +#[test] +fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + let pos = ChunkPos::new(0, 0, 0); + + let mut region = RegionFile::open(path.clone())?; + region.write_chunk(pos, &sample(pos), 0)?; + region.save()?; + + // A leftover .tmp from an interrupted save must be ignored: only the renamed target is read. + fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?; + let reopened = RegionFile::open(path)?; + assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); + Ok(()) +} diff --git a/crates/server/src/tests/world_server.rs b/crates/server/src/tests/world_server.rs new file mode 100644 index 0000000..89650d4 --- /dev/null +++ b/crates/server/src/tests/world_server.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; +use shared::generator::{VoxelGenerator, WorldGenConfig}; +use shared::save::SaveError; +use shared::world::{BlockId, ChunkData, ChunkPos}; +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use crate::save::{RegionFile, SaveRequest, region_path}; + +/// Builds a generator with a small, cheap terrain configuration for streaming tests. +fn test_generator() -> VoxelGenerator { + let config = WorldGenConfig { + base_height: 8, + noise_scale: 0.05, + surface_block: BlockId(1), + subsurface_block: BlockId(2), + stone_block: BlockId(3), + }; + VoxelGenerator::new(config, 42) +} + +/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache. +fn test_world(region_dir: std::path::PathBuf) -> ServerWorld { + let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN); + ServerWorld::new(test_generator(), region_dir, capacity) +} + +/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout. +fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet) -> StreamStats { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let stats = world.reconcile(desired); + if stats.in_flight == 0 { + return stats; + } + assert!( + Instant::now() < deadline, + "worker pool did not drain in time" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable. +fn flush(world: &ServerWorld) -> Result<(), SaveError> { + let (reply_tx, reply_rx) = crossbeam_channel::bounded(1); + // A send error means the actor has already stopped, leaving nothing to flush. + if world + .save_tx + .send(SaveRequest::Flush { reply: reply_tx }) + .is_err() + { + return Ok(()); + } + reply_rx.recv().unwrap_or(Ok(())) +} + +#[test] +fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> { + // A fresh empty directory means every load is a miss and resolves to the baseline. + let dir = tempfile::tempdir()?; + let mut world = test_world(dir.path().to_path_buf()); + let mut desired = HashSet::new(); + cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired); + + // The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight. + let first = world.reconcile(&desired); + assert_eq!(first.loaded, 0); + assert_eq!(first.resident, 0); + assert!(first.in_flight > 0); + + // Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident. + let final_stats = drain_to_idle(&mut world, &desired); + assert_eq!(final_stats.in_flight, 0); + assert_eq!(final_stats.resident, desired.len()); + Ok(()) +} + +#[test] +fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let mut world = test_world(dir.path().to_path_buf()); + let target = ChunkPos::new(0, 0, 0); + let mut desired = HashSet::new(); + desired.insert(target); + + // Dispatch the chunk, then immediately stop wanting it. + world.reconcile(&desired); + + // Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted. + let empty = HashSet::new(); + let final_stats = drain_to_idle(&mut world, &empty); + assert_eq!(final_stats.in_flight, 0); + assert_eq!(final_stats.resident, 0); + Ok(()) +} + +#[test] +fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> { + // A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline. + let dir = tempfile::tempdir()?; + let pos = ChunkPos::new(0, 0, 0); + let edited_index = 100u32; + let edited_block = BlockId(999); + + let mut data = ChunkData::new(pos, 0); + data.set(edited_index, edited_block); + + let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?; + region.write_chunk(pos, &data, 0)?; + region.save()?; + + let mut world = test_world(dir.path().to_path_buf()); + let mut desired = HashSet::new(); + desired.insert(pos); + drain_to_idle(&mut world, &desired); + + // The resident chunk must carry the stored edit layered over its regenerated baseline. + assert!( + world + .chunk(pos) + .is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block) + ); + Ok(()) +} + +#[test] +fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> { + // A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip. + let dir = tempfile::tempdir()?; + let pos = ChunkPos::new(0, 0, 0); + let edited_index = 100usize; + let edited_block = BlockId(999); + + let mut world = test_world(dir.path().to_path_buf()); + let mut desired = HashSet::new(); + desired.insert(pos); + drain_to_idle(&mut world, &desired); + + // Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates. + assert!( + world + .chunks + .get_mut(&pos) + .map(|chunk| chunk.blocks[edited_index] = edited_block) + .is_some() + ); + + // Reconciling against an empty desired set evicts the chunk, sending its diff to the actor. + world.reconcile(&HashSet::new()); + // The flush shares the eviction's sender, so its reply confirms the write-back is on disk. + flush(&world)?; + + // A fresh world over the same directory must stream the chunk back with the edit intact. + let mut reloaded = test_world(dir.path().to_path_buf()); + drain_to_idle(&mut reloaded, &desired); + assert!( + reloaded + .chunk(pos) + .is_some_and(|chunk| chunk.blocks[edited_index] == edited_block) + ); + Ok(()) +} + +#[test] +fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> { + // An unmodified chunk equals its baseline, so eviction must persist no record for it. + let dir = tempfile::tempdir()?; + let pos = ChunkPos::new(0, 0, 0); + + let mut world = test_world(dir.path().to_path_buf()); + let mut desired = HashSet::new(); + desired.insert(pos); + drain_to_idle(&mut world, &desired); + + // Evict without modifying the chunk, then flush. + world.reconcile(&HashSet::new()); + flush(&world)?; + + // No record may exist for a chunk that never diverged from its baseline. + let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?; + assert!(region.read_chunk(pos)?.is_none()); + Ok(()) +} + +#[test] +fn cylinder_contains_expected_columns() { + let mut set = HashSet::new(); + cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set); + + assert!(set.contains(&ChunkPos::new(0, 0, 0))); + // A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4). + assert!(!set.contains(&ChunkPos::new(2, 0, 2))); + // An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4). + assert!(set.contains(&ChunkPos::new(2, 0, 0))); + // The vertical extent is radius/2 = 1, so y=2 is out of range. + assert!(!set.contains(&ChunkPos::new(0, 2, 0))); + assert!(set.contains(&ChunkPos::new(0, 1, 0))); +} + +#[test] +fn cylinder_translates_with_center() { + let mut origin = HashSet::new(); + cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin); + + let mut shifted = HashSet::new(); + cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted); + + // The shape is translation-invariant: the same count regardless of center. + assert_eq!(origin.len(), shifted.len()); +} diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index 4cff871..3d4738b 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -274,216 +274,5 @@ pub fn cylinder_chunks( } #[cfg(test)] -mod tests { - use super::*; - use shared::generator::{VoxelGenerator, WorldGenConfig}; - use shared::save::SaveError; - use shared::world::{BlockId, ChunkData, ChunkPos}; - use std::collections::HashSet; - use std::time::{Duration, Instant}; - - use crate::save::{RegionFile, SaveRequest, region_path}; - - /// Builds a generator with a small, cheap terrain configuration for streaming tests. - fn test_generator() -> VoxelGenerator { - let config = WorldGenConfig { - base_height: 8, - noise_scale: 0.05, - surface_block: BlockId(1), - subsurface_block: BlockId(2), - stone_block: BlockId(3), - }; - VoxelGenerator::new(config, 42) - } - - /// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache. - fn test_world(region_dir: std::path::PathBuf) -> ServerWorld { - let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN); - ServerWorld::new(test_generator(), region_dir, capacity) - } - - /// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout. - fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet) -> StreamStats { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let stats = world.reconcile(desired); - if stats.in_flight == 0 { - return stats; - } - assert!( - Instant::now() < deadline, - "worker pool did not drain in time" - ); - std::thread::sleep(Duration::from_millis(1)); - } - } - - /// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable. - fn flush(world: &ServerWorld) -> Result<(), SaveError> { - let (reply_tx, reply_rx) = crossbeam_channel::bounded(1); - // A send error means the actor has already stopped, leaving nothing to flush. - if world - .save_tx - .send(SaveRequest::Flush { reply: reply_tx }) - .is_err() - { - return Ok(()); - } - reply_rx.recv().unwrap_or(Ok(())) - } - - #[test] - fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> { - // A fresh empty directory means every load is a miss and resolves to the baseline. - let dir = tempfile::tempdir()?; - let mut world = test_world(dir.path().to_path_buf()); - let mut desired = HashSet::new(); - cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired); - - // The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight. - let first = world.reconcile(&desired); - assert_eq!(first.loaded, 0); - assert_eq!(first.resident, 0); - assert!(first.in_flight > 0); - - // Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident. - let final_stats = drain_to_idle(&mut world, &desired); - assert_eq!(final_stats.in_flight, 0); - assert_eq!(final_stats.resident, desired.len()); - Ok(()) - } - - #[test] - fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> { - let dir = tempfile::tempdir()?; - let mut world = test_world(dir.path().to_path_buf()); - let target = ChunkPos::new(0, 0, 0); - let mut desired = HashSet::new(); - desired.insert(target); - - // Dispatch the chunk, then immediately stop wanting it. - world.reconcile(&desired); - - // Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted. - let empty = HashSet::new(); - let final_stats = drain_to_idle(&mut world, &empty); - assert_eq!(final_stats.in_flight, 0); - assert_eq!(final_stats.resident, 0); - Ok(()) - } - - #[test] - fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> { - // A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline. - let dir = tempfile::tempdir()?; - let pos = ChunkPos::new(0, 0, 0); - let edited_index = 100u32; - let edited_block = BlockId(999); - - let mut data = ChunkData::new(pos, 0); - data.set(edited_index, edited_block); - - let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?; - region.write_chunk(pos, &data, 0)?; - region.save()?; - - let mut world = test_world(dir.path().to_path_buf()); - let mut desired = HashSet::new(); - desired.insert(pos); - drain_to_idle(&mut world, &desired); - - // The resident chunk must carry the stored edit layered over its regenerated baseline. - assert!( - world - .chunk(pos) - .is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block) - ); - Ok(()) - } - - #[test] - fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> { - // A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip. - let dir = tempfile::tempdir()?; - let pos = ChunkPos::new(0, 0, 0); - let edited_index = 100usize; - let edited_block = BlockId(999); - - let mut world = test_world(dir.path().to_path_buf()); - let mut desired = HashSet::new(); - desired.insert(pos); - drain_to_idle(&mut world, &desired); - - // Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates. - assert!( - world - .chunks - .get_mut(&pos) - .map(|chunk| chunk.blocks[edited_index] = edited_block) - .is_some() - ); - - // Reconciling against an empty desired set evicts the chunk, sending its diff to the actor. - world.reconcile(&HashSet::new()); - // The flush shares the eviction's sender, so its reply confirms the write-back is on disk. - flush(&world)?; - - // A fresh world over the same directory must stream the chunk back with the edit intact. - let mut reloaded = test_world(dir.path().to_path_buf()); - drain_to_idle(&mut reloaded, &desired); - assert!( - reloaded - .chunk(pos) - .is_some_and(|chunk| chunk.blocks[edited_index] == edited_block) - ); - Ok(()) - } - - #[test] - fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> { - // An unmodified chunk equals its baseline, so eviction must persist no record for it. - let dir = tempfile::tempdir()?; - let pos = ChunkPos::new(0, 0, 0); - - let mut world = test_world(dir.path().to_path_buf()); - let mut desired = HashSet::new(); - desired.insert(pos); - drain_to_idle(&mut world, &desired); - - // Evict without modifying the chunk, then flush. - world.reconcile(&HashSet::new()); - flush(&world)?; - - // No record may exist for a chunk that never diverged from its baseline. - let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?; - assert!(region.read_chunk(pos)?.is_none()); - Ok(()) - } - - #[test] - fn cylinder_contains_expected_columns() { - let mut set = HashSet::new(); - cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set); - - assert!(set.contains(&ChunkPos::new(0, 0, 0))); - // A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4). - assert!(!set.contains(&ChunkPos::new(2, 0, 2))); - // An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4). - assert!(set.contains(&ChunkPos::new(2, 0, 0))); - // The vertical extent is radius/2 = 1, so y=2 is out of range. - assert!(!set.contains(&ChunkPos::new(0, 2, 0))); - assert!(set.contains(&ChunkPos::new(0, 1, 0))); - } - - #[test] - fn cylinder_translates_with_center() { - let mut origin = HashSet::new(); - cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin); - - let mut shifted = HashSet::new(); - cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted); - - // The shape is translation-invariant: the same count regardless of center. - assert_eq!(origin.len(), shifted.len()); - } -} +#[path = "tests/world_server.rs"] +mod tests; diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs index 570e998..a2b25b2 100644 --- a/crates/shared/src/protocol.rs +++ b/crates/shared/src/protocol.rs @@ -194,90 +194,5 @@ impl Default for StreamLayout { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stream_layout_default_is_canonical() { - let layout = StreamLayout::default(); - assert_eq!(layout.control, 0); - assert_eq!(layout.input, 1); - assert_eq!(layout.authority, 2); - assert_eq!(layout.chunk_lod0, 3); - assert_eq!(layout.chunk_lod1, 4); - assert_eq!(layout.chunk_lod2, 5); - assert_eq!(layout.chunk_lod3, 6); - assert_eq!(layout.chunk_lod4, 7); - assert_eq!(layout.asset, 8); - assert_eq!(layout.mod_data, 9); - } - - fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> { - let bytes = postcard::to_stdvec(msg)?; - let decoded: ControlMessage = postcard::from_bytes(&bytes)?; - assert_eq!(msg, &decoded); - Ok(()) - } - - #[test] - fn roundtrip_client_hello() -> Result<(), postcard::Error> { - let msg = ControlMessage::ClientHello(ClientHello { - protocol_version: PROTOCOL_VERSION, - client_build: "synvael-client-0.1.0".to_string(), - player_identity: PlayerIdentity { - display_name: "Player1".to_string(), - }, - installed_packs: vec![PackRef { - id: "core:base".to_string(), - version: "1.0.0".to_string(), - content_hash: [0; 32], - tier: PackTier::Data, - }], - requested_features: FeatureFlags(0), - }); - roundtrip_test(&msg) - } - - #[test] - fn roundtrip_handshake_ack() -> Result<(), postcard::Error> { - let msg = ControlMessage::HandshakeAck(HandshakeAck { - protocol_version: PROTOCOL_VERSION, - server_build: "synvael-server-0.1.0".to_string(), - world_packs: vec![], - missing_packs: vec![], - stream_layout: StreamLayout::default(), - tick_rate_hint: 20, - }); - roundtrip_test(&msg) - } - - #[test] - fn roundtrip_handshake_reject() -> Result<(), postcard::Error> { - let msg = ControlMessage::HandshakeReject(HandshakeReject { - reason: RejectReason::ProtocolMismatch, - detail: "Expected v1, got v2".to_string(), - upgrade_url: Some("https://synvael.example/download".to_string()), - }); - roundtrip_test(&msg) - } - - #[test] - fn pack_tier_match_rules() { - // Resource packs are client-side overlays: a mismatch is always tolerated. - assert!(!PackTier::Resource.requires_strict_match()); - // Data packs affect authoritative content and must match exactly. - assert!(PackTier::Data.requires_strict_match()); - // A client-only mod cannot desync server state, so a mismatch is tolerated. - assert!(!PackTier::Mod { client_only: true }.requires_strict_match()); - // A server-affecting mod must match exactly. - assert!(PackTier::Mod { client_only: false }.requires_strict_match()); - } - - #[test] - fn roundtrip_disconnect() -> Result<(), postcard::Error> { - let msg = ControlMessage::Disconnect(Disconnect { - reason: "Server closing".to_string(), - }); - roundtrip_test(&msg) - } -} +#[path = "tests/protocol.rs"] +mod tests; diff --git a/crates/shared/src/save/record.rs b/crates/shared/src/save/record.rs index aa13e49..78503de 100644 --- a/crates/shared/src/save/record.rs +++ b/crates/shared/src/save/record.rs @@ -106,73 +106,5 @@ pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> { } #[cfg(test)] -mod tests { - use super::*; - use crate::world::{BlockId, ChunkPos}; - - /// Builds a representative modified chunk with a few edits spanning the local index range. - fn sample() -> ChunkData { - let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7); - data.set(0, BlockId(4)); - data.set(1000, BlockId(9)); - data.set(32_767, BlockId(2)); - data - } - - #[test] - fn round_trips_payload_and_metadata() -> Result<(), SaveError> { - let data = sample(); - let bytes = encode(&data, 123_456)?; - let (meta, decoded) = decode(&bytes)?; - - assert_eq!(decoded, data); - assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION); - assert_eq!(meta.flags, 0); - assert_eq!(meta.last_modified, 123_456); - Ok(()) - } - - #[test] - fn rejects_bad_magic() -> Result<(), SaveError> { - let mut bytes = encode(&sample(), 0)?; - bytes[0] = b'X'; - assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. }))); - Ok(()) - } - - #[test] - fn rejects_truncated_header() -> Result<(), SaveError> { - let bytes = encode(&sample(), 0)?; - // A buffer shorter than the fixed header cannot yield a full record. - assert!(matches!( - decode(&bytes[..HEADER_LEN - 1]), - Err(SaveError::Truncated { .. }) - )); - Ok(()) - } - - #[test] - fn rejects_truncated_payload() -> Result<(), SaveError> { - let bytes = encode(&sample(), 0)?; - // Keep the whole header but cut the compressed payload short. - assert!(matches!( - decode(&bytes[..=HEADER_LEN]), - Err(SaveError::Truncated { .. }) - )); - Ok(()) - } - - #[test] - fn detects_declared_length_mismatch() -> Result<(), SaveError> { - let mut bytes = encode(&sample(), 0)?; - // The uncompressed-length field is the u32 at offset 16 (after magic, version, - // flags, and the timestamp). Overwriting it with a value the payload cannot - // decompress to must be caught by the post-decompression length check. - bytes[16..20].copy_from_slice(&1u32.to_le_bytes()); - assert!(matches!( - decode(&bytes), - Err(SaveError::LengthMismatch { .. }) - )); - Ok(()) - } -} +#[path = "../tests/record.rs"] +mod tests; diff --git a/crates/shared/src/save/region.rs b/crates/shared/src/save/region.rs index 1c64fa6..b670bd2 100644 --- a/crates/shared/src/save/region.rs +++ b/crates/shared/src/save/region.rs @@ -261,97 +261,5 @@ fn len_u32(len: usize) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - /// Builds a populated index with entries at varied positions, a free span, and a stamp exception. - fn sample() -> RegionIndex { - let mut index = RegionIndex::new(3); - index.insert( - ChunkPos::new(0, 0, 0), - HeaderEntry { - offset: 4096, - length: 128, - flags: 0, - }, - ); - index.insert( - ChunkPos::new(-5, 12, -30), - HeaderEntry { - offset: 8192, - length: 256, - flags: 0, - }, - ); - index.push_free(FreeSpan { - offset: 512, - length: 64, - }); - index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2); - index.bump_tile_version(); - index - } - - #[test] - fn round_trips_index() -> Result<(), SaveError> { - let index = sample(); - let decoded = RegionIndex::decode(&index.encode()?)?; - assert_eq!(decoded, index); - Ok(()) - } - - #[test] - fn preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> { - // Regression guard: base and stamp worldgen versions are u32, so a value that would not - // fit a u16 must survive encode -> decode without truncation. - let mut index = RegionIndex::new(70_000); - index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000); - let decoded = RegionIndex::decode(&index.encode()?)?; - assert_eq!(decoded.base_worldgen_version(), 70_000); - assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000); - Ok(()) - } - - #[test] - fn stamp_equal_to_base_is_not_recorded() { - let mut index = RegionIndex::new(7); - // A stamp equal to the base is redundant, so no exception entry is stored. - index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7); - assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7); - assert!(index.stamps.is_empty()); - } - - #[test] - fn rejects_bad_magic() -> Result<(), SaveError> { - let mut bytes = sample().encode()?; - bytes[0] = b'X'; - assert!(matches!( - RegionIndex::decode(&bytes), - Err(SaveError::BadMagic { .. }) - )); - Ok(()) - } - - #[test] - fn rejects_unsupported_version() -> Result<(), SaveError> { - let mut bytes = sample().encode()?; - // The format_version u32 sits just after the 4 magic bytes. - bytes[4..8].copy_from_slice(&999u32.to_le_bytes()); - assert!(matches!( - RegionIndex::decode(&bytes), - Err(SaveError::UnsupportedVersion { .. }) - )); - Ok(()) - } - - #[test] - fn rejects_truncated_table() -> Result<(), SaveError> { - let bytes = sample().encode()?; - // Cut the buffer mid-header-table so an entry read runs off the end. - assert!(matches!( - RegionIndex::decode(&bytes[..20]), - Err(SaveError::Truncated { .. }) - )); - Ok(()) - } -} +#[path = "../tests/region.rs"] +mod tests; diff --git a/crates/shared/src/tests/chunk.rs b/crates/shared/src/tests/chunk.rs new file mode 100644 index 0000000..e73a244 --- /dev/null +++ b/crates/shared/src/tests/chunk.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +/// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size. +fn chunk_cycling(distinct: usize) -> Chunk { + let mut chunk = Chunk::default(); + for (i, block) in chunk.blocks.iter_mut().enumerate() { + // `distinct` is a small test constant, so the modulo result always fits in a u16. + #[expect( + clippy::cast_possible_truncation, + reason = "distinct is a small test constant within u16 range" + )] + let id = (i % distinct) as u16; + *block = BlockId(id); + } + chunk +} + +#[test] +fn bit_width_matches_palette_size() { + // The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide. + assert_eq!(PalettedChunk::bits_for_palette(1), 1); + assert_eq!(PalettedChunk::bits_for_palette(2), 1); + assert_eq!(PalettedChunk::bits_for_palette(3), 2); + assert_eq!(PalettedChunk::bits_for_palette(4), 2); + assert_eq!(PalettedChunk::bits_for_palette(5), 3); + assert_eq!(PalettedChunk::bits_for_palette(8), 3); + assert_eq!(PalettedChunk::bits_for_palette(9), 4); + assert_eq!(PalettedChunk::bits_for_palette(16), 4); + assert_eq!(PalettedChunk::bits_for_palette(17), 5); +} + +#[test] +fn round_trip_preserves_all_voxels() { + // Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1). + for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] { + let original = chunk_cycling(distinct); + let paletted = PalettedChunk::from_chunk(&original); + assert_eq!( + paletted.palette.len(), + distinct, + "palette must hold exactly the distinct materials for {distinct}" + ); + let restored = paletted.to_chunk(); + assert_eq!( + original.blocks, restored.blocks, + "round trip must preserve every voxel for {distinct} materials" + ); + } +} + +#[test] +fn all_air_chunk_has_single_entry_palette() { + let paletted = PalettedChunk::from_chunk(&Chunk::default()); + assert_eq!(paletted.palette, vec![BlockId::AIR]); + assert_eq!(paletted.bits_per_index, 1); + assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks); +} + +#[test] +fn preserves_index_straddling_word_boundary() { + // With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling. + let mut original = chunk_cycling(5); + original.blocks[21] = BlockId(4); + let restored = PalettedChunk::from_chunk(&original).to_chunk(); + assert_eq!(restored.blocks[21], BlockId(4)); + assert_eq!(original.blocks, restored.blocks); +} diff --git a/crates/shared/src/tests/chunk_data.rs b/crates/shared/src/tests/chunk_data.rs new file mode 100644 index 0000000..0ba7137 --- /dev/null +++ b/crates/shared/src/tests/chunk_data.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +/// A baseline chunk with a recognisable, non-uniform fill so edits are distinguishable from it. +fn baseline() -> Chunk { + let mut chunk = Chunk::default(); + chunk.set(0, 0, 0, BlockId(1)); + chunk.set(1, 2, 3, BlockId(2)); + chunk +} + +#[test] +fn materialize_reproduces_diffed_chunk() { + let base = baseline(); + let mut current = base.clone(); + current.set(5, 6, 7, BlockId(9)); + current.set(0, 0, 0, BlockId(3)); + + let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t); + let restored = data.materialize(&base); + + assert_eq!(restored.blocks, current.blocks); +} + +#[test] +fn identical_chunk_diffs_to_nothing() { + let base = baseline(); + let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &base.clone()); + assert!(data.is_unmodified()); + assert_eq!(data.edits.len(), 0); +} + +#[test] +fn reverted_edit_leaves_no_entry() { + let base = baseline(); + let mut current = base.clone(); + // Change a voxel and then change it straight back to its baseline value. + current.set(4, 4, 4, BlockId(7)); + current.set(4, 4, 4, base.get(4, 4, 4)); + + let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t); + assert!(data.is_unmodified()); +} + +#[test] +fn diff_stores_only_changed_voxels() { + let base = baseline(); + let mut current = base.clone(); + current.set(1, 1, 1, BlockId(4)); + current.set(2, 2, 2, BlockId(5)); + current.set(3, 3, 3, BlockId(6)); + + let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t); + assert_eq!(data.edits.len(), 3); +} + +#[test] +fn set_does_not_reconcile_against_baseline() { + // `set` is deliberately dumb: writing a baseline-equal value still records an entry. + let mut data = ChunkData::new(ChunkPos::new(0, 0, 0), 0); + data.set(42, BlockId::AIR); + assert_eq!(data.edits.len(), 1); + assert!(!data.is_unmodified()); +} diff --git a/crates/shared/src/tests/coords.rs b/crates/shared/src/tests/coords.rs new file mode 100644 index 0000000..0378989 --- /dev/null +++ b/crates/shared/src/tests/coords.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +#[test] +fn from_world_maps_positive_positions() { + // A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63). + assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1); + // The last block of chunk 0 (block 31) stays in chunk 0. + assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0); +} + +#[test] +fn from_world_floors_negative_positions() { + // Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract. + assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1); + // Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33). + assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2); +} + +#[test] +fn from_world_floors_fractional_positions() { + // A position of -0.5 lies inside block -1, which is in chunk -1. + assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1); +} diff --git a/crates/shared/src/tests/entity.rs b/crates/shared/src/tests/entity.rs new file mode 100644 index 0000000..f940f34 --- /dev/null +++ b/crates/shared/src/tests/entity.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +#[expect( + clippy::cast_precision_loss, + reason = "CHUNK_SIZE is 32, exactly representable as f32" +)] +const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32; + +#[test] +fn renormalize_leaves_in_range_offsets_untouched() { + // A local offset already inside [0, CHUNK_SIZE) must not move the anchor. + let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0)); + pos.renormalize(); + assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3)); + assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6)); +} + +#[test] +fn renormalize_carries_positive_overflow() { + // One block past the chunk's far edge lands in the next chunk at local 1.0. + let over = CHUNK_SIZE_F + 1.0; + let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0)); + pos.renormalize(); + assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0)); + assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6)); +} + +#[test] +fn renormalize_borrows_on_negative_offset() { + // The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero. + let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0)); + pos.renormalize(); + assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0)); + assert!( + pos.local + .abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6) + ); +} + +#[test] +fn renormalize_carries_multiple_chunks() { + // A large offset carries more than one chunk in a single call. + let far = CHUNK_SIZE_F * 2.0 + 6.0; + let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0)); + pos.renormalize(); + assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0)); + assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6)); +} diff --git a/crates/shared/src/tests/protocol.rs b/crates/shared/src/tests/protocol.rs new file mode 100644 index 0000000..dee3794 --- /dev/null +++ b/crates/shared/src/tests/protocol.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +#[test] +fn stream_layout_default_is_canonical() { + let layout = StreamLayout::default(); + assert_eq!(layout.control, 0); + assert_eq!(layout.input, 1); + assert_eq!(layout.authority, 2); + assert_eq!(layout.chunk_lod0, 3); + assert_eq!(layout.chunk_lod1, 4); + assert_eq!(layout.chunk_lod2, 5); + assert_eq!(layout.chunk_lod3, 6); + assert_eq!(layout.chunk_lod4, 7); + assert_eq!(layout.asset, 8); + assert_eq!(layout.mod_data, 9); +} + +fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> { + let bytes = postcard::to_stdvec(msg)?; + let decoded: ControlMessage = postcard::from_bytes(&bytes)?; + assert_eq!(msg, &decoded); + Ok(()) +} + +#[test] +fn roundtrip_client_hello() -> Result<(), postcard::Error> { + let msg = ControlMessage::ClientHello(ClientHello { + protocol_version: PROTOCOL_VERSION, + client_build: "synvael-client-0.1.0".to_string(), + player_identity: PlayerIdentity { + display_name: "Player1".to_string(), + }, + installed_packs: vec![PackRef { + id: "core:base".to_string(), + version: "1.0.0".to_string(), + content_hash: [0; 32], + tier: PackTier::Data, + }], + requested_features: FeatureFlags(0), + }); + roundtrip_test(&msg) +} + +#[test] +fn roundtrip_handshake_ack() -> Result<(), postcard::Error> { + let msg = ControlMessage::HandshakeAck(HandshakeAck { + protocol_version: PROTOCOL_VERSION, + server_build: "synvael-server-0.1.0".to_string(), + world_packs: vec![], + missing_packs: vec![], + stream_layout: StreamLayout::default(), + tick_rate_hint: 20, + }); + roundtrip_test(&msg) +} + +#[test] +fn roundtrip_handshake_reject() -> Result<(), postcard::Error> { + let msg = ControlMessage::HandshakeReject(HandshakeReject { + reason: RejectReason::ProtocolMismatch, + detail: "Expected v1, got v2".to_string(), + upgrade_url: Some("https://synvael.example/download".to_string()), + }); + roundtrip_test(&msg) +} + +#[test] +fn pack_tier_match_rules() { + // Resource packs are client-side overlays: a mismatch is always tolerated. + assert!(!PackTier::Resource.requires_strict_match()); + // Data packs affect authoritative content and must match exactly. + assert!(PackTier::Data.requires_strict_match()); + // A client-only mod cannot desync server state, so a mismatch is tolerated. + assert!(!PackTier::Mod { client_only: true }.requires_strict_match()); + // A server-affecting mod must match exactly. + assert!(PackTier::Mod { client_only: false }.requires_strict_match()); +} + +#[test] +fn roundtrip_disconnect() -> Result<(), postcard::Error> { + let msg = ControlMessage::Disconnect(Disconnect { + reason: "Server closing".to_string(), + }); + roundtrip_test(&msg) +} diff --git a/crates/shared/src/tests/record.rs b/crates/shared/src/tests/record.rs new file mode 100644 index 0000000..afbee95 --- /dev/null +++ b/crates/shared/src/tests/record.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; +use crate::world::{BlockId, ChunkPos}; + +/// Builds a representative modified chunk with a few edits spanning the local index range. +fn sample() -> ChunkData { + let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7); + data.set(0, BlockId(4)); + data.set(1000, BlockId(9)); + data.set(32_767, BlockId(2)); + data +} + +#[test] +fn round_trips_payload_and_metadata() -> Result<(), SaveError> { + let data = sample(); + let bytes = encode(&data, 123_456)?; + let (meta, decoded) = decode(&bytes)?; + + assert_eq!(decoded, data); + assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION); + assert_eq!(meta.flags, 0); + assert_eq!(meta.last_modified, 123_456); + Ok(()) +} + +#[test] +fn rejects_bad_magic() -> Result<(), SaveError> { + let mut bytes = encode(&sample(), 0)?; + bytes[0] = b'X'; + assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. }))); + Ok(()) +} + +#[test] +fn rejects_truncated_header() -> Result<(), SaveError> { + let bytes = encode(&sample(), 0)?; + // A buffer shorter than the fixed header cannot yield a full record. + assert!(matches!( + decode(&bytes[..HEADER_LEN - 1]), + Err(SaveError::Truncated { .. }) + )); + Ok(()) +} + +#[test] +fn rejects_truncated_payload() -> Result<(), SaveError> { + let bytes = encode(&sample(), 0)?; + // Keep the whole header but cut the compressed payload short. + assert!(matches!( + decode(&bytes[..=HEADER_LEN]), + Err(SaveError::Truncated { .. }) + )); + Ok(()) +} + +#[test] +fn detects_declared_length_mismatch() -> Result<(), SaveError> { + let mut bytes = encode(&sample(), 0)?; + // The uncompressed-length field is the u32 at offset 16 (after magic, version, + // flags, and the timestamp). Overwriting it with a value the payload cannot + // decompress to must be caught by the post-decompression length check. + bytes[16..20].copy_from_slice(&1u32.to_le_bytes()); + assert!(matches!( + decode(&bytes), + Err(SaveError::LengthMismatch { .. }) + )); + Ok(()) +} diff --git a/crates/shared/src/tests/region.rs b/crates/shared/src/tests/region.rs new file mode 100644 index 0000000..2873163 --- /dev/null +++ b/crates/shared/src/tests/region.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +use super::*; + +/// Builds a populated index with entries at varied positions, a free span, and a stamp exception. +fn sample() -> RegionIndex { + let mut index = RegionIndex::new(3); + index.insert( + ChunkPos::new(0, 0, 0), + HeaderEntry { + offset: 4096, + length: 128, + flags: 0, + }, + ); + index.insert( + ChunkPos::new(-5, 12, -30), + HeaderEntry { + offset: 8192, + length: 256, + flags: 0, + }, + ); + index.push_free(FreeSpan { + offset: 512, + length: 64, + }); + index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2); + index.bump_tile_version(); + index +} + +#[test] +fn round_trips_index() -> Result<(), SaveError> { + let index = sample(); + let decoded = RegionIndex::decode(&index.encode()?)?; + assert_eq!(decoded, index); + Ok(()) +} + +#[test] +fn preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> { + // Regression guard: base and stamp worldgen versions are u32, so a value that would not + // fit a u16 must survive encode -> decode without truncation. + let mut index = RegionIndex::new(70_000); + index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000); + let decoded = RegionIndex::decode(&index.encode()?)?; + assert_eq!(decoded.base_worldgen_version(), 70_000); + assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000); + Ok(()) +} + +#[test] +fn stamp_equal_to_base_is_not_recorded() { + let mut index = RegionIndex::new(7); + // A stamp equal to the base is redundant, so no exception entry is stored. + index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7); + assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7); + assert!(index.stamps.is_empty()); +} + +#[test] +fn rejects_bad_magic() -> Result<(), SaveError> { + let mut bytes = sample().encode()?; + bytes[0] = b'X'; + assert!(matches!( + RegionIndex::decode(&bytes), + Err(SaveError::BadMagic { .. }) + )); + Ok(()) +} + +#[test] +fn rejects_unsupported_version() -> Result<(), SaveError> { + let mut bytes = sample().encode()?; + // The format_version u32 sits just after the 4 magic bytes. + bytes[4..8].copy_from_slice(&999u32.to_le_bytes()); + assert!(matches!( + RegionIndex::decode(&bytes), + Err(SaveError::UnsupportedVersion { .. }) + )); + Ok(()) +} + +#[test] +fn rejects_truncated_table() -> Result<(), SaveError> { + let bytes = sample().encode()?; + // Cut the buffer mid-header-table so an entry read runs off the end. + assert!(matches!( + RegionIndex::decode(&bytes[..20]), + Err(SaveError::Truncated { .. }) + )); + Ok(()) +} diff --git a/crates/shared/src/world/chunk.rs b/crates/shared/src/world/chunk.rs index fb4385d..f62b328 100644 --- a/crates/shared/src/world/chunk.rs +++ b/crates/shared/src/world/chunk.rs @@ -153,72 +153,5 @@ impl PalettedChunk { } #[cfg(test)] -mod tests { - use super::*; - - /// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size. - fn chunk_cycling(distinct: usize) -> Chunk { - let mut chunk = Chunk::default(); - for (i, block) in chunk.blocks.iter_mut().enumerate() { - // `distinct` is a small test constant, so the modulo result always fits in a u16. - #[expect( - clippy::cast_possible_truncation, - reason = "distinct is a small test constant within u16 range" - )] - let id = (i % distinct) as u16; - *block = BlockId(id); - } - chunk - } - - #[test] - fn bit_width_matches_palette_size() { - // The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide. - assert_eq!(PalettedChunk::bits_for_palette(1), 1); - assert_eq!(PalettedChunk::bits_for_palette(2), 1); - assert_eq!(PalettedChunk::bits_for_palette(3), 2); - assert_eq!(PalettedChunk::bits_for_palette(4), 2); - assert_eq!(PalettedChunk::bits_for_palette(5), 3); - assert_eq!(PalettedChunk::bits_for_palette(8), 3); - assert_eq!(PalettedChunk::bits_for_palette(9), 4); - assert_eq!(PalettedChunk::bits_for_palette(16), 4); - assert_eq!(PalettedChunk::bits_for_palette(17), 5); - } - - #[test] - fn round_trip_preserves_all_voxels() { - // Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1). - for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] { - let original = chunk_cycling(distinct); - let paletted = PalettedChunk::from_chunk(&original); - assert_eq!( - paletted.palette.len(), - distinct, - "palette must hold exactly the distinct materials for {distinct}" - ); - let restored = paletted.to_chunk(); - assert_eq!( - original.blocks, restored.blocks, - "round trip must preserve every voxel for {distinct} materials" - ); - } - } - - #[test] - fn all_air_chunk_has_single_entry_palette() { - let paletted = PalettedChunk::from_chunk(&Chunk::default()); - assert_eq!(paletted.palette, vec![BlockId::AIR]); - assert_eq!(paletted.bits_per_index, 1); - assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks); - } - - #[test] - fn preserves_index_straddling_word_boundary() { - // With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling. - let mut original = chunk_cycling(5); - original.blocks[21] = BlockId(4); - let restored = PalettedChunk::from_chunk(&original).to_chunk(); - assert_eq!(restored.blocks[21], BlockId(4)); - assert_eq!(original.blocks, restored.blocks); - } -} +#[path = "../tests/chunk.rs"] +mod tests; diff --git a/crates/shared/src/world/chunk_data.rs b/crates/shared/src/world/chunk_data.rs index 6a23157..3941e42 100644 --- a/crates/shared/src/world/chunk_data.rs +++ b/crates/shared/src/world/chunk_data.rs @@ -89,68 +89,5 @@ impl ChunkData { } #[cfg(test)] -mod tests { - use super::*; - - /// A baseline chunk with a recognisable, non-uniform fill so edits are distinguishable from it. - fn baseline() -> Chunk { - let mut chunk = Chunk::default(); - chunk.set(0, 0, 0, BlockId(1)); - chunk.set(1, 2, 3, BlockId(2)); - chunk - } - - #[test] - fn materialize_reproduces_diffed_chunk() { - let base = baseline(); - let mut current = base.clone(); - current.set(5, 6, 7, BlockId(9)); - current.set(0, 0, 0, BlockId(3)); - - let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t); - let restored = data.materialize(&base); - - assert_eq!(restored.blocks, current.blocks); - } - - #[test] - fn identical_chunk_diffs_to_nothing() { - let base = baseline(); - let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &base.clone()); - assert!(data.is_unmodified()); - assert_eq!(data.edits.len(), 0); - } - - #[test] - fn reverted_edit_leaves_no_entry() { - let base = baseline(); - let mut current = base.clone(); - // Change a voxel and then change it straight back to its baseline value. - current.set(4, 4, 4, BlockId(7)); - current.set(4, 4, 4, base.get(4, 4, 4)); - - let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t); - assert!(data.is_unmodified()); - } - - #[test] - fn diff_stores_only_changed_voxels() { - let base = baseline(); - let mut current = base.clone(); - current.set(1, 1, 1, BlockId(4)); - current.set(2, 2, 2, BlockId(5)); - current.set(3, 3, 3, BlockId(6)); - - let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t); - assert_eq!(data.edits.len(), 3); - } - - #[test] - fn set_does_not_reconcile_against_baseline() { - // `set` is deliberately dumb: writing a baseline-equal value still records an entry. - let mut data = ChunkData::new(ChunkPos::new(0, 0, 0), 0); - data.set(42, BlockId::AIR); - assert_eq!(data.edits.len(), 1); - assert!(!data.is_unmodified()); - } -} +#[path = "../tests/chunk_data.rs"] +mod tests; diff --git a/crates/shared/src/world/coords.rs b/crates/shared/src/world/coords.rs index fa00270..6bac1f3 100644 --- a/crates/shared/src/world/coords.rs +++ b/crates/shared/src/world/coords.rs @@ -40,28 +40,5 @@ impl ChunkPos { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn from_world_maps_positive_positions() { - // A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63). - assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1); - // The last block of chunk 0 (block 31) stays in chunk 0. - assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0); - } - - #[test] - fn from_world_floors_negative_positions() { - // Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract. - assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1); - // Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33). - assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2); - } - - #[test] - fn from_world_floors_fractional_positions() { - // A position of -0.5 lies inside block -1, which is in chunk -1. - assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1); - } -} +#[path = "../tests/coords.rs"] +mod tests; diff --git a/crates/shared/src/world/entity.rs b/crates/shared/src/world/entity.rs index f9597b9..8c0c4a2 100644 --- a/crates/shared/src/world/entity.rs +++ b/crates/shared/src/world/entity.rs @@ -44,53 +44,5 @@ impl EntityPos { } #[cfg(test)] -mod tests { - use super::*; - - #[expect( - clippy::cast_precision_loss, - reason = "CHUNK_SIZE is 32, exactly representable as f32" - )] - const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32; - - #[test] - fn renormalize_leaves_in_range_offsets_untouched() { - // A local offset already inside [0, CHUNK_SIZE) must not move the anchor. - let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0)); - pos.renormalize(); - assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3)); - assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6)); - } - - #[test] - fn renormalize_carries_positive_overflow() { - // One block past the chunk's far edge lands in the next chunk at local 1.0. - let over = CHUNK_SIZE_F + 1.0; - let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0)); - pos.renormalize(); - assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0)); - assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6)); - } - - #[test] - fn renormalize_borrows_on_negative_offset() { - // The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero. - let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0)); - pos.renormalize(); - assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0)); - assert!( - pos.local - .abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6) - ); - } - - #[test] - fn renormalize_carries_multiple_chunks() { - // A large offset carries more than one chunk in a single call. - let far = CHUNK_SIZE_F * 2.0 + 6.0; - let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0)); - pos.renormalize(); - assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0)); - assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6)); - } -} +#[path = "../tests/entity.rs"] +mod tests; From 30a45f1c414b0639bacba058933cdd76c9d7a81a Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 14 Jul 2026 01:18:53 +0200 Subject: [PATCH 17/21] docs(net): trim handshake pack-validation TODO comment --- crates/net/src/handshake.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs index 5f871fb..570ef59 100644 --- a/crates/net/src/handshake.rs +++ b/crates/net/src/handshake.rs @@ -137,7 +137,7 @@ pub async fn accept_connection( }); } - // TODO: validate installed_packs against the world's required packs once the modlist-matching concept lands. M1 accepts any pack set and advertises none. + // TODO: validate installed_packs against the world's required packs once the modlist-matching concept lands. let ack = HandshakeAck { protocol_version: PROTOCOL_VERSION, server_build, From e9d6854e7a2e96a8c63569b56fdc1a5755acf1b1 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 14 Jul 2026 01:38:50 +0200 Subject: [PATCH 18/21] feat(net): add threaded tokio runtime bridge for connections --- crates/net/src/lib.rs | 6 + crates/net/src/runtime.rs | 260 +++++++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 2 deletions(-) diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 594e421..7bfe863 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -12,6 +12,12 @@ pub mod error; pub mod handshake; pub mod runtime; +pub use runtime::{ConnectOutcome, NetworkServer, ServerEvent, connect_in_background}; + +/// Default UDP port the server binds and the client connects to when none is configured. +// TODO: make the bind address and port configurable through server/client configuration. +pub const DEFAULT_PORT: u16 = 25565; + #[cfg(test)] #[path = "tests/handshake.rs"] mod handshake_tests; diff --git a/crates/net/src/runtime.rs b/crates/net/src/runtime.rs index 8f7328b..b5b021a 100644 --- a/crates/net/src/runtime.rs +++ b/crates/net/src/runtime.rs @@ -1,5 +1,261 @@ // SPDX-License-Identifier: AGPL-3.0-only //! Threaded `tokio` runtime bridge between the async network and the synchronous simulation. -//! -//! Owns the runtime thread and the channels that carry inbound and outbound messages across the async-sync boundary, keeping the async runtime confined to this crate. Populated in a later concept; currently a placeholder. + +use std::net::SocketAddr; +use std::thread; + +use shared::protocol::{ClientHello, HandshakeAck}; +use tracing::{info, warn}; + +use crate::endpoint::{client_endpoint, server_endpoint}; +use crate::error::NetError; +use crate::handshake::{ServerConnection, accept_connection, connect}; + +/// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure. +pub type ConnectOutcome = crossbeam_channel::Receiver>; + +/// An event surfaced by the network thread to the synchronous server loop. +#[derive(Debug)] +pub enum ServerEvent { + /// A client completed the Synvael handshake. Carries the stable per-session id and the `ClientHello` it presented. + ClientConnected { + /// Stable identifier assigned to this session for the lifetime of the connection. + id: u64, + /// The identity and build parameters the client advertised. + hello: ClientHello, + }, + /// A previously connected client's session ended. + ClientDisconnected { + /// Identifier of the session that ended, matching the earlier [`ServerEvent::ClientConnected`]. + id: u64, + /// Human-readable description of why the connection closed. + reason: String, + }, +} + +/// Handle to the background networking thread and its owned `tokio` runtime. +#[derive(Debug)] +pub struct NetworkServer { + /// Events produced by the accept loop, drained by the synchronous simulation thread. + events: crossbeam_channel::Receiver, + /// Shutdown signal. Dropping this sender resolves the accept loop's receiver and breaks the loop. + shutdown: Option>, + /// Join handle for the network thread, awaited on drop for an orderly shutdown. + thread: Option>, +} + +impl NetworkServer { + /// Spawns a dedicated network thread, builds a current-thread `tokio` runtime on it, binds a QUIC server endpoint on `bind`, and runs the accept loop. + /// + /// # Errors + /// + /// Returns [`NetError::Io`] if the runtime cannot be built, the network thread cannot be spawned, or the thread exits before reporting a bound address, and any error from [`server_endpoint`] if the endpoint cannot be constructed or bound. + pub fn spawn( + bind: SocketAddr, + server_build: String, + tick_rate_hint: u16, + ) -> Result<(Self, SocketAddr), NetError> { + let (events_tx, events_rx) = crossbeam_channel::unbounded(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + // Reports the bound address (or the error that prevented binding) back to this thread, so `spawn` can surface it synchronously. + let (ready_tx, ready_rx) = crossbeam_channel::bounded::>(1); + + let thread = thread::Builder::new() + .name("net-server".to_owned()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = ready_tx.send(Err(NetError::Io(error))); + return; + } + }; + + runtime.block_on(async move { + // The endpoint is constructed inside the runtime context because `quinn` binds its driver task onto the current runtime. + let endpoint = match server_endpoint(bind) { + Ok(endpoint) => endpoint, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return; + } + }; + let local_addr = match endpoint.local_addr() { + Ok(addr) => addr, + Err(error) => { + let _ = ready_tx.send(Err(NetError::Io(error))); + return; + } + }; + // If the caller has already given up, there is nothing to serve. + if ready_tx.send(Ok(local_addr)).is_err() { + return; + } + + accept_loop( + endpoint, + events_tx, + shutdown_rx, + server_build, + tick_rate_hint, + ) + .await; + }); + }) + .map_err(NetError::Io)?; + + let local_addr = match ready_rx.recv() { + Ok(Ok(addr)) => addr, + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err(NetError::Io(std::io::Error::other( + "network thread exited before reporting a bound address", + ))); + } + }; + + Ok(( + Self { + events: events_rx, + shutdown: Some(shutdown_tx), + thread: Some(thread), + }, + local_addr, + )) + } + + /// Drains every [`ServerEvent`] currently queued, without blocking. + pub fn poll_events(&self) -> impl Iterator + '_ { + self.events.try_iter() + } +} + +impl Drop for NetworkServer { + fn drop(&mut self) { + // Dropping the sender resolves the accept loop's shutdown receiver, breaking the loop and letting `block_on` return so the thread unwinds and the runtime is dropped. + self.shutdown.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// Accepts incoming QUIC connections until the endpoint stops yielding them or a shutdown is signalled, spawning one handshake task per connection. +async fn accept_loop( + endpoint: quinn::Endpoint, + events: crossbeam_channel::Sender, + mut shutdown: tokio::sync::oneshot::Receiver<()>, + server_build: String, + tick_rate_hint: u16, +) { + // Session ids are handed out sequentially; the accept loop is the sole assigner, so a plain counter suffices. + let mut next_id: u64 = 0; + loop { + tokio::select! { + incoming = endpoint.accept() => { + let Some(incoming) = incoming else { break }; + let id = next_id; + next_id += 1; + tokio::spawn(handle_connection( + incoming, + id, + events.clone(), + server_build.clone(), + tick_rate_hint, + )); + } + // Resolves when the `NetworkServer` handle is dropped (sender gone) or an explicit signal is sent. + _ = &mut shutdown => break, + } + } + info!("network accept loop shutting down"); +} + +/// Performs the handshake for one incoming connection and, on success, reports connect and disconnect events for its session. +async fn handle_connection( + incoming: quinn::Incoming, + id: u64, + events: crossbeam_channel::Sender, + server_build: String, + tick_rate_hint: u16, +) { + match accept_connection(incoming, server_build, tick_rate_hint).await { + Ok(ServerConnection { + connection, hello, .. + }) => { + // If the receiver is gone the server is shutting down; drop the connection silently. + if events + .send(ServerEvent::ClientConnected { id, hello }) + .is_err() + { + return; + } + let reason = connection.closed().await; + let _ = events.send(ServerEvent::ClientDisconnected { + id, + reason: reason.to_string(), + }); + } + Err(error) => { + warn!(%error, id, "connection handshake failed"); + } + } +} + +/// Runs a one-shot connect and Synvael handshake against `server_addr` on a background `tokio` thread, reporting the outcome to the returned receiver. +#[must_use] +pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> ConnectOutcome { + let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1); + // Retained so a failure to spawn the thread can still be reported to the caller. + let spawn_err_tx = outcome_tx.clone(); + + let spawned = thread::Builder::new() + .name("net-client".to_owned()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = outcome_tx.send(Err(format!("failed to build tokio runtime: {error}"))); + return; + } + }; + + runtime.block_on(async move { + let endpoint = match client_endpoint() { + Ok(endpoint) => endpoint, + Err(error) => { + let _ = outcome_tx.send(Err(error.to_string())); + return; + } + }; + + // The server name matches the self-signed certificate's subject; the current verifier accepts any certificate regardless. + match connect(&endpoint, server_addr, "localhost", hello).await { + Ok(connected) => { + if outcome_tx.send(Ok(connected.ack.clone())).is_err() { + return; + } + // Keep the connection alive on the network thread until the server closes it. A full client session pump is a later concept. + let reason = connected.connection.closed().await; + info!(%reason, "server connection closed"); + } + Err(error) => { + let _ = outcome_tx.send(Err(error.to_string())); + } + } + }); + }); + + if let Err(error) = spawned { + let _ = spawn_err_tx.send(Err(format!("failed to spawn network thread: {error}"))); + } + + outcome_rx +} From e9414158361f22cc3d021d486f342b83febb62d2 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 14 Jul 2026 01:38:58 +0200 Subject: [PATCH 19/21] feat(server): accept QUIC client connections --- Cargo.lock | 2 ++ crates/server/Cargo.toml | 1 + crates/server/src/main.rs | 29 +++++++++++++++++++++-------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5e29b2..b94db69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -548,6 +548,7 @@ dependencies = [ "anyhow", "ash-window", "glam 0.33.2", + "net", "raw-window-handle", "renderer", "serde_json", @@ -2421,6 +2422,7 @@ dependencies = [ "crossbeam-channel", "glam 0.33.2", "lru", + "net", "serde_json", "shared", "tempfile", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 81108d4..dcc2722 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -14,6 +14,7 @@ bevy_ecs = "0.19" crossbeam-channel = "0.5.16" glam.workspace = true lru = "0.18.1" +net = { version = "0.1.0", path = "../net" } serde_json.workspace = true shared = { path = "../shared" } tracing.workspace = true diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 6f967ac..2c38f8f 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -15,6 +15,8 @@ pub mod world_server; use std::collections::HashSet; use std::fs; +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; use anyhow::Context; use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World}; @@ -23,6 +25,7 @@ use shared::generator::{VoxelGenerator, WorldGenConfig}; use shared::world::{ChunkPos, EntityPos}; use tracing::{debug, info}; +use net::NetworkServer; use player::{Player, Position, ViewDistance}; use world_server::{ServerWorld, cylinder_chunks}; @@ -116,16 +119,26 @@ fn main() -> anyhow::Result<()> { } info!("Initial region ready; granting player control"); - // Drive several ticks, marching the dummy one chunk along +X between each. The manual movement stands in for network-driven player input and exists only to exercise load/unload as the anchor moves. - for step in 0..5 { - info!(step, "tick"); + // Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events. + let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT)); + let (network, local_addr) = NetworkServer::spawn( + bind, + env!("CARGO_PKG_VERSION").to_owned(), + // Placeholder advisory tick rate. + 20, + ) + .context("spawning network server")?; + info!(%local_addr, "network endpoint listening"); + + // Authoritative simulation loop. + loop { schedule.run(&mut world); - let mut movers = world.query_filtered::<&mut Position, With>(); - for mut position in movers.iter_mut(&mut world) { - position.0.chunk.x += 1; + for event in network.poll_events() { + info!(?event, "network event"); } - } - Ok(()) + // Advisory ~20 Hz cadence until the real tick scheduler lands. + std::thread::sleep(Duration::from_millis(50)); + } } From 7620c3b54e79884249ccb3a64b4257f175b4c587 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 14 Jul 2026 01:38:58 +0200 Subject: [PATCH 20/21] feat(client): connect to server and complete handshake --- crates/client/Cargo.toml | 1 + crates/client/src/main.rs | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index da5ab9c..76f90d6 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -19,3 +19,4 @@ raw-window-handle.workspace = true ash-window.workspace = true serde_json.workspace = true shared = { path = "../shared" } +net = { version = "0.1.0", path = "../net" } diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index e1683aa..7c40862 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -56,6 +56,8 @@ struct App { input: InputState, /// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame. last_frame: Option, + /// Receives the outcome of the background connect and handshake, drained non-blocking from the event loop. `None` before the connection is started and once the outcome has been observed. + handshake_rx: Option, } impl Default for App { @@ -71,6 +73,7 @@ impl Default for App { ), input: InputState::default(), last_frame: None, + handshake_rx: None, } } } @@ -177,6 +180,21 @@ impl ApplicationHandler for App { .expect("Renderer initialized") .update_mesh(&vertices, &indices) .expect("Failed to upload terrain to GPU"); + + // Kick off a background connect + handshake to the local server. + let hello = shared::protocol::ClientHello { + protocol_version: shared::protocol::PROTOCOL_VERSION, + client_build: env!("CARGO_PKG_VERSION").to_owned(), + player_identity: shared::protocol::PlayerIdentity { + display_name: "Player".to_owned(), + }, + installed_packs: Vec::new(), + requested_features: shared::protocol::FeatureFlags(0), + }; + let server_addr = + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT)); + info!("Connecting to server at {server_addr}"); + self.handshake_rx = Some(net::connect_in_background(server_addr, hello)); } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { @@ -200,6 +218,29 @@ impl ApplicationHandler for App { } } WindowEvent::RedrawRequested => { + // Non-blocking check for the handshake outcome. + let mut handshake_done = false; + if let Some(rx) = self.handshake_rx.as_ref() { + match rx.try_recv() { + Ok(Ok(ack)) => { + info!( + protocol_version = ack.protocol_version, + "handshake complete" + ); + handshake_done = true; + } + Ok(Err(reason)) => { + warn!("handshake failed: {reason}"); + handshake_done = true; + } + // Empty: not ready yet. Disconnected: the network thread ended. + Err(_) => {} + } + } + if handshake_done { + self.handshake_rx = None; + } + // Derive delta-time from the previous frame so movement is framerate-independent. The first frame has no predecessor and therefore advances by zero seconds. let now = Instant::now(); let dt = self From bb33247f968d0237d47378410c5c5a72cca3c588 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 15 Jul 2026 18:48:21 +0200 Subject: [PATCH 21/21] chore(net): modified doc comments --- crates/net/src/handshake.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs index 570ef59..2051b07 100644 --- a/crates/net/src/handshake.rs +++ b/crates/net/src/handshake.rs @@ -1,10 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only //! Synvael application handshake over an established QUIC connection. -//! -//! Drives the `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange and protocol-version verification on the control stream (stream 0). The QUIC/TLS handshake is completed by the transport layer before any of these functions run; the exchange here is the Synvael application handshake layered on top of it. -//! -//! The control stream `(SendStream, RecvStream)` is retained inside the returned handles ([`Connected`], [`ServerConnection`]) and is never finished after the handshake, so later concepts can reuse stream 0 for chat, commands, and disconnect. use quinn::{Connection, Incoming, RecvStream, SendStream, VarInt}; use shared::protocol::{