Merge pull request #1 from Cryoforge-Nexus/feat/player-streaming

feat(server): player-driven async chunk streaming
This commit is contained in:
Serkyo 2026-07-07 23:15:10 +02:00 committed by GitHub
commit 705345b337
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 910 additions and 83 deletions

532
Cargo.lock generated
View file

@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.3.4",
"once_cell", "once_cell",
"version_check", "version_check",
"zerocopy", "zerocopy",
@ -109,11 +109,48 @@ dependencies = [
"raw-window-metal", "raw-window-metal",
] ]
[[package]]
name = "assert_type_match"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
@ -121,6 +158,168 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bevy_ecs"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4654b9b3535fa7813d2878a50e1b5ad80a361dc1c913b96ded57667f65d70a01"
dependencies = [
"arrayvec",
"bevy_ecs_macros",
"bevy_platform",
"bevy_ptr",
"bevy_reflect",
"bevy_tasks",
"bevy_utils",
"bitflags 2.11.1",
"bumpalo",
"concurrent-queue",
"derive_more",
"fixedbitset",
"indexmap",
"log",
"nonmax",
"serde",
"slotmap",
"smallvec",
"thiserror 2.0.18",
"variadics_please",
]
[[package]]
name = "bevy_ecs_macro_logic"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee0b98a74141ce8dce4654c60020ebbaefab32646f42af6bbae55f282ae65ff7"
dependencies = [
"bevy_macro_utils",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bevy_ecs_macros"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dc958a194d226d618404e0fea99a3c8b4146207a00a3ba07fa712bf71607240"
dependencies = [
"bevy_ecs_macro_logic",
"bevy_macro_utils",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bevy_macro_utils"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "746a19912c6dc1bbe79188778573e8a253d5832c696b2fcb95578c17b29ff7ba"
dependencies = [
"proc-macro2",
"quote",
"syn",
"toml_edit",
]
[[package]]
name = "bevy_platform"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3120670bb2308980f723477c9d82476fcda31f08be9b256c3f0acdee82a65094"
dependencies = [
"critical-section",
"foldhash",
"futures-channel",
"futures-lite",
"hashbrown 0.16.1",
"js-sys",
"portable-atomic",
"portable-atomic-util",
"serde",
"spin",
"wasm-bindgen",
"wasm-bindgen-futures",
"windows-sys 0.61.2",
]
[[package]]
name = "bevy_ptr"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b511e89f7078fd21fdea77061a0ca3e737297c7011bb10c073e0aecb17c9fea6"
[[package]]
name = "bevy_reflect"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92abd59cbba1524c1847e9a50f74d94e6b7836c80a8edfa9c47e59f7fd81021d"
dependencies = [
"assert_type_match",
"bevy_platform",
"bevy_ptr",
"bevy_reflect_derive",
"bevy_utils",
"derive_more",
"disqualified",
"downcast-rs 2.0.2",
"erased-serde",
"foldhash",
"glam 0.32.1",
"indexmap",
"serde",
"smallvec",
"smol_str",
"thiserror 2.0.18",
"uuid",
"variadics_please",
"wgpu-types",
]
[[package]]
name = "bevy_reflect_derive"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fa4e6b97fd2d582dd5ed96762a70d04505a477c833cf4f69d016dcd03d2d04"
dependencies = [
"bevy_macro_utils",
"indexmap",
"proc-macro2",
"quote",
"syn",
"uuid",
]
[[package]]
name = "bevy_tasks"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa34e6b9b804851ec08a41c0346c859d82e2fa98c906d74b965ee61bbbb688e4"
dependencies = [
"async-executor",
"async-task",
"atomic-waker",
"bevy_platform",
"crossbeam-queue",
"derive_more",
"futures-lite",
"heapless",
"web-task",
]
[[package]]
name = "bevy_utils"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aceea6c7ffdd568f866d5ca4dfe50c33440c54f7bc230c13a9ba7ab0c91aed1"
dependencies = [
"bevy_platform",
"disqualified",
"indexmap",
"thread_local",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -132,6 +331,9 @@ name = "bitflags"
version = "2.11.1" version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "block" name = "block"
@ -174,6 +376,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@ -293,6 +501,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
"portable-atomic",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
] ]
[[package]] [[package]]
@ -336,20 +554,25 @@ dependencies = [
] ]
[[package]] [[package]]
name = "crossbeam-deque" name = "critical-section"
version = "0.8.7" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [ dependencies = [
"crossbeam-epoch",
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-queue"
version = "0.9.20" version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
@ -366,12 +589,41 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
"unicode-xid",
]
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "disqualified"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd"
[[package]] [[package]]
name = "dlib" name = "dlib"
version = "0.5.3" version = "0.5.3"
@ -387,24 +639,35 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "downcast-rs"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@ -427,6 +690,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@ -454,12 +729,40 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@ -496,10 +799,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 5.3.0",
"wasip2", "wasip2",
] ]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
]
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.27.0" version = "0.27.0"
@ -515,6 +829,15 @@ version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
[[package]]
name = "glam"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "gpu-allocator" name = "gpu-allocator"
version = "0.28.0" version = "0.28.0"
@ -531,12 +854,43 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"equivalent",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.0" version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heapless"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [
"hash32",
"portable-atomic",
"stable_deref_trait",
]
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
@ -550,7 +904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.0",
] ]
[[package]] [[package]]
@ -623,7 +977,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.3.4",
"libc", "libc",
] ]
@ -765,6 +1119,12 @@ dependencies = [
"rand_xorshift", "rand_xorshift",
] ]
[[package]]
name = "nonmax"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -1072,6 +1432,12 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -1130,6 +1496,21 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "presser" name = "presser"
version = "0.3.1" version = "0.3.1"
@ -1178,6 +1559,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.6" version = "0.8.6"
@ -1220,26 +1607,6 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
] ]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.4.1" version = "0.4.1"
@ -1415,8 +1782,9 @@ dependencies = [
name = "server" name = "server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bevy_ecs",
"crossbeam-channel",
"glam 0.27.0", "glam 0.27.0",
"rayon",
"serde_json", "serde_json",
"shared", "shared",
"tracing", "tracing",
@ -1471,6 +1839,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
@ -1511,6 +1888,21 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
dependencies = [
"portable-atomic",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "strict-num" name = "strict-num"
version = "0.1.1" version = "0.1.1"
@ -1699,6 +2091,12 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -1711,12 +2109,41 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "uuid"
version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "variadics_please"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -1804,7 +2231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [ dependencies = [
"cc", "cc",
"downcast-rs", "downcast-rs 1.2.1",
"rustix 1.1.4", "rustix 1.1.4",
"scoped-tls", "scoped-tls",
"smallvec", "smallvec",
@ -1916,6 +2343,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-task"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cdc136a53ccd64a1211f107ccc34404769fbcc0f165f1afa065f5d88ab93538"
dependencies = [
"async-task",
"cfg-if",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web-time" name = "web-time"
version = "1.1.0" version = "1.1.0"
@ -1926,6 +2365,21 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "wgpu-types"
version = "29.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5"
dependencies = [
"bitflags 2.11.1",
"bytemuck",
"js-sys",
"log",
"raw-window-handle",
"serde",
"web-sys",
]
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"

Binary file not shown.

Binary file not shown.

View file

@ -9,8 +9,9 @@ authors.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
bevy_ecs = "0.19"
crossbeam-channel = "0.5.16"
glam = "0.27" glam = "0.27"
rayon = "1.12.0"
serde_json = "1.0.149" serde_json = "1.0.149"
shared = { version = "0.1.0", path = "../shared" } shared = { version = "0.1.0", path = "../shared" }
tracing = "0.1.44" tracing = "0.1.44"

View file

@ -5,16 +5,44 @@
//! The server handles the authoritative game simulation, including world //! The server handles the authoritative game simulation, including world
//! management, physics, and combat. //! management, physics, and combat.
/// Entity components describing players and other world-streaming anchors.
pub mod player;
/// Authoritative chunk storage and generation logic for the server. /// Authoritative chunk storage and generation logic for the server.
pub mod world_server; pub mod world_server;
use std::collections::HashSet;
use std::fs; use std::fs;
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World};
use glam::Vec3; use glam::Vec3;
use shared::generator::{VoxelGenerator, WorldGenConfig}; use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::{ChunkPos, EntityPos}; use shared::world::{ChunkPos, EntityPos};
use tracing::{debug, info}; use tracing::{debug, info};
use player::{Player, Position, ViewDistance};
use world_server::{ServerWorld, cylinder_chunks};
/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor.
fn stream_chunks(
anchors: Query<(&Position, &ViewDistance), With<Player>>,
mut world: ResMut<ServerWorld>,
) {
// Desired set is the union of every anchor's cylinder; a chunk survives as long as it lies within any one player's view.
let mut desired = HashSet::new();
for (position, view) in &anchors {
cylinder_chunks(position.0.chunk, view.0, &mut desired);
}
let stats = world.reconcile(&desired);
info!(
loaded = stats.loaded,
unloaded = stats.unloaded,
resident = stats.resident,
in_flight = stats.in_flight,
"streaming reconcile"
);
}
fn main() { fn main() {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
@ -44,10 +72,47 @@ fn main() {
let generator = VoxelGenerator::new(worldgen_config, seed); let generator = VoxelGenerator::new(worldgen_config, seed);
let mut world = world_server::ServerWorld::new(generator); let mut world = World::new();
world.insert_resource(ServerWorld::new(generator));
// Hardcoded spawn stand-in until a real player entity exists; the chunk anchor drives streaming. // Spawn a single dummy player anchor at the world origin.
let player = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO); world.spawn((
Player,
Position(EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO)),
ViewDistance(4),
));
world.load_around(player, 4); // A schedule is one tick's worth of systems; running it advances the world.
let mut schedule = Schedule::default();
schedule.add_systems(stream_chunks);
// Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control.
info!("Streaming initial region");
loop {
schedule.run(&mut world);
let server_world = world.resource::<ServerWorld>();
let resident = server_world.loaded_count();
let in_flight = server_world.in_flight_count();
// Loading progress is simply the resident fraction of all known chunks.
let total = resident + in_flight;
debug!(resident, in_flight, total, "loading progress");
// The region is ready once at least one chunk has been generated and none remain in flight.
if server_world.streaming_idle() && resident > 0 {
break;
}
}
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");
schedule.run(&mut world);
let mut movers = world.query_filtered::<&mut Position, With<Player>>();
for mut position in movers.iter_mut(&mut world) {
position.0.chunk.x += 1;
}
}
} }

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Entity components describing players and other world-streaming anchors.
use bevy_ecs::prelude::Component;
use shared::world::EntityPos;
/// Marker component identifying an entity as a player.
#[derive(Component)]
pub struct Player;
/// Authoritative world-space position of an entity.
#[derive(Component)]
pub struct Position(pub EntityPos);
/// Chunk-streaming radius, in chunks, of the cylinder loaded around an entity.
#[derive(Component)]
pub struct ViewDistance(pub i32);

View file

@ -2,72 +2,273 @@
//! Authoritative chunk storage and generation logic for the server. //! Authoritative chunk storage and generation logic for the server.
use rayon::prelude::*; use bevy_ecs::prelude::Resource;
use crossbeam_channel::{Receiver, Sender};
use shared::{ use shared::{
generator::VoxelGenerator, generator::VoxelGenerator,
world::{Chunk, ChunkPos, EntityPos}, world::{Chunk, ChunkPos},
}; };
use std::collections::HashMap; use std::{
collections::{HashMap, HashSet},
sync::Arc,
thread::JoinHandle,
};
/// Outcome of a single streaming reconcile pass, for logging and tests.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StreamStats {
/// Number of chunks generated and inserted this pass.
pub loaded: usize,
/// Number of chunks evicted this pass.
pub unloaded: usize,
/// Number of chunks resident after the pass.
pub resident: usize,
/// Number of chunks in flight.
pub in_flight: usize,
}
/// The server's authoritative representation of the world. /// The server's authoritative representation of the world.
#[derive(Resource)]
pub struct ServerWorld { pub struct ServerWorld {
generator: VoxelGenerator, /// Currently resident chunks, keyed by chunk-space position.
chunks: HashMap<ChunkPos, Chunk>, chunks: HashMap<ChunkPos, Chunk>,
/// Sending end of the job channel; the main thread pushes positions that require generation.
job_tx: Sender<ChunkPos>,
/// Receiving end of the result channel; the main thread drains finished chunks returned by workers.
result_rx: Receiver<(ChunkPos, Chunk)>,
/// Positions dispatched to a worker but not yet returned, preventing the same chunk from being re-dispatched on subsequent passes.
in_flight: HashSet<ChunkPos>,
/// Handles to the generation worker threads, retained so they 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)]
workers: Vec<JoinHandle<()>>,
} }
impl ServerWorld { impl ServerWorld {
/// Initializes a new authoritative server world with the provided generator. /// Initializes a new authoritative server world with the provided generator.
#[must_use] #[must_use]
pub fn new(generator: VoxelGenerator) -> Self { pub fn new(generator: VoxelGenerator) -> Self {
let generator = Arc::new(generator);
let (job_tx, job_rx) = crossbeam_channel::unbounded::<ChunkPos>();
let (result_tx, result_rx) = crossbeam_channel::unbounded::<(ChunkPos, Chunk)>();
// One worker per logical core, falling back to a small fixed pool if the platform cannot report its parallelism.
let worker_count = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
let workers = (0..worker_count)
.map(|_| {
// Each worker owns its own clones: a shared handle to the read-only generator, its own view of the shared job queue, and its own sender back into the result channel.
let generator = Arc::clone(&generator);
let job_rx = job_rx.clone();
let result_tx = result_tx.clone();
std::thread::spawn(move || {
// Block until a job arrives.
while let Ok(pos) = job_rx.recv() {
let chunk = generator.generate_chunk(pos);
// A send error means the main thread has gone away; nothing left to do but let the worker wind down.
if result_tx.send((pos, chunk)).is_err() {
break;
}
}
})
})
.collect();
// Drop the template ends left over after cloning.
drop(job_rx);
drop(result_tx);
Self { Self {
generator,
chunks: HashMap::new(), chunks: HashMap::new(),
job_tx,
result_rx,
in_flight: HashSet::new(),
workers,
} }
} }
/// Retrieves a reference to the chunk at the given position, generating it if necessary. /// Number of chunks currently resident in memory.
pub fn get_chunk(&mut self, pos: ChunkPos) -> &Chunk { #[must_use]
self.chunks pub fn loaded_count(&self) -> usize {
.entry(pos) self.chunks.len()
.or_insert_with(|| self.generator.generate_chunk(pos))
} }
/// Generates and caches all chunks in a cylindrical region around the given position. /// Number of chunks dispatched to the worker pool but not yet returned.
pub fn load_around(&mut self, center: EntityPos, radius: i32) { #[must_use]
// The chunk anchor is the exact streaming key; no lossy world-to-chunk conversion is needed. pub fn in_flight_count(&self) -> usize {
let center = center.chunk; self.in_flight.len()
}
// Phase 1: collect the positions inside the cylinder that are not yet resident /// Returns `true` when the worker pool has no outstanding generation work, i.e. every dispatched chunk has been returned. A loading gate can poll this to decide when an initial region has finished streaming.
let mut pending = Vec::new(); #[must_use]
for x in center.x - radius..=center.x + radius { pub fn streaming_idle(&self) -> bool {
for z in center.z - radius..=center.z + radius { self.in_flight.is_empty()
for y in center.y - radius / 2..=center.y + radius / 2 { }
let dx = x - center.x;
let dz = z - center.z;
if (dx * dx + dz * dz) <= radius * radius { /// Reconciles resident chunks against a desired set without blocking the caller: finished chunks are drained from the worker pool, resident chunks absent from `desired` are evicted, and still-missing chunks are dispatched to the pool.
let pos = ChunkPos::new(x, y, z); pub fn reconcile(&mut self, desired: &HashSet<ChunkPos>) -> StreamStats {
if !self.chunks.contains_key(&pos) { // Drain: absorb every chunk the workers have finished since the last pass.
pending.push(pos); let mut loaded = 0;
} while let Ok((pos, chunk)) = self.result_rx.try_recv() {
} // The position is no longer dispatched now that its chunk has returned.
} self.in_flight.remove(&pos);
// Guard against the eviction race: the anchor may have moved away while this chunk was generating, so a returned chunk is only kept if it is still wanted.
if desired.contains(&pos) {
self.chunks.insert(pos, chunk);
loaded += 1;
} }
} }
// Phase 2: generate the missing chunks in parallel // Evict: drop resident chunks that no anchor wants any more.
let generator = &self.generator; let stale: Vec<ChunkPos> = self
let generated: Vec<(ChunkPos, Chunk)> = pending .chunks
.into_par_iter() .keys()
.map(|pos| (pos, generator.generate_chunk(pos))) .filter(|pos| !desired.contains(pos))
.copied()
.collect(); .collect();
for pos in &stale {
// Phase 3: move the finished chunks into the map self.chunks.remove(pos);
let count = generated.len();
for (pos, chunk) in generated {
self.chunks.insert(pos, chunk);
} }
tracing::info!(chunks = count, center = ?center, radius, "generated chunk cylinder"); // Dispatch: request generation for every wanted position that is neither resident nor already in flight.
for &pos in desired {
if !self.chunks.contains_key(&pos) && !self.in_flight.contains(&pos) {
self.in_flight.insert(pos);
// A send error means every worker has shut down; there is nothing useful to do with the position, so the failure is ignored.
let _ = self.job_tx.send(pos);
}
}
StreamStats {
loaded,
unloaded: stale.len(),
resident: self.chunks.len(),
in_flight: self.in_flight.len(),
}
}
}
/// Inserts every chunk position inside the streaming cylinder around `center` into `out`.
pub fn cylinder_chunks<S: std::hash::BuildHasher>(
center: ChunkPos,
radius: i32,
out: &mut HashSet<ChunkPos, S>,
) {
for x in center.x - radius..=center.x + radius {
for z in center.z - radius..=center.z + radius {
let dx = x - center.x;
let dz = z - center.z;
// Keep only the columns whose XZ distance falls within the disc.
if dx * dx + dz * dz <= radius * radius {
for y in center.y - radius / 2..=center.y + radius / 2 {
out.insert(ChunkPos::new(x, y, z));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::{ChunkPos, ServerWorld, StreamStats, cylinder_chunks};
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::BlockId;
use std::collections::HashSet;
use std::time::{Duration, Instant};
/// Builds a server world backed by a real worker pool for streaming tests.
fn test_world() -> ServerWorld {
let config = WorldGenConfig {
base_height: 8,
noise_scale: 0.05,
surface_block: BlockId(1),
subsurface_block: BlockId(2),
stone_block: BlockId(3),
};
ServerWorld::new(VoxelGenerator::new(config, 42))
}
/// Repeatedly reconciles against `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<ChunkPos>) -> 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));
}
}
#[test]
fn reconcile_converges_over_multiple_passes() {
let mut world = test_world();
let mut desired = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
// The first pass only dispatches work; because generation 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());
}
#[test]
fn evicted_chunk_is_not_repopulated_on_arrival() {
let mut world = test_world();
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 whenever the worker returns the chunk the drain guard discards it rather than resurrecting an unwanted chunk.
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,
"a chunk that is no longer wanted must not become resident when it arrives"
);
}
#[test]
fn cylinder_is_symmetric_and_bounded() {
let mut set = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
// Center column is always included.
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
// A corner outside the disc (dx=2, dz=2 -> 8 > 4) is excluded.
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)));
// 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);
// Shape is translation-invariant: same count regardless of center.
assert_eq!(origin.len(), shifted.len());
} }
} }

View file

@ -37,11 +37,13 @@ Each subsystem note should name the design topic it implements (by title, e.g. "
- [`adr/0005-namespaced-content-ids.md`](adr/0005-namespaced-content-ids.md): namespaced content IDs. - [`adr/0005-namespaced-content-ids.md`](adr/0005-namespaced-content-ids.md): namespaced content IDs.
- [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API. - [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API.
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API. - [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API.
- [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions.
- [`adr/template.md`](adr/template.md): template for new decisions. - [`adr/template.md`](adr/template.md): template for new decisions.
Subsystem notes: Subsystem notes:
- [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution). - [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution).
- [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import). - [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import).
- [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline.
Further subsystem notes are added here as systems are implemented and locked. Further subsystem notes are added here as systems are implemented and locked.

View file

@ -0,0 +1,26 @@
# 0008. Split-coordinate entity positions
- **Status:** Accepted
- **Date:** 2026-07-07
## Context
In a procedurally generated voxel world, an entity's position cannot be robustly represented by a single global single-precision floating-point vector (`f32` or `Vec3`). At large distances from the origin, the spacing between representable floating-point numbers increases, leading to spatial jitter, physics instability, and rendering artifacts.
While double-precision floats (`f64`) postpone this issue, they double the data size and are not uniformly or natively supported on GPUs, which expect `f32` vertices and transforms. The engine requires a spatial representation that maintains sub-millimeter precision universally across a theoretically unbounded world, without coupling the simulation state directly to GPU limitations or paying the cost of `f64` everywhere.
## Decision
The engine uses a split-coordinate representation for entity positions, encapsulated by the `EntityPos` type. An entity's absolute position is defined by two discrete components:
1. A `chunk` anchor (`ChunkPos`): The integer coordinates of the chunk containing the entity.
2. A `local` offset (`Vec3`): A single-precision floating-point vector describing the entity's exact position relative to the chunk's minimum corner.
When an entity moves, the movement is applied to the `local` offset. A normalization step (`EntityPos::renormalize`) then carries any overflow beyond the chunk boundaries into the integer `chunk` anchor, ensuring the `local` offset always remains strictly within the bounding box of a single chunk (`[0.0, CHUNK_SIZE)`).
## Consequences
- **Uniform Precision:** Entities maintain exact `f32` precision regardless of how far they travel from the world origin, as the active floating-point magnitude is strictly bounded by the size of a single chunk.
- **Rendering Stability:** The renderer can compute relative matrices by defining the camera's current chunk as the origin. This allows the GPU to process all vertex data and transforms in standard `f32` without any spatial jitter.
- **Math Complexity:** Code manipulating spatial positions (like physics integration and distance checks) becomes more complex. It is no longer possible to simply subtract two global vectors; logic must handle both the chunk offset and the local offset simultaneously.
- **Serialization:** `EntityPos` serializes as a compound struct, ensuring save files do not lose coordinate precision for distant entities.

60
docs/chunk_streaming.md Normal file
View file

@ -0,0 +1,60 @@
# Chunk streaming
How the server keeps the set of resident chunks in sync with where players are, and how chunk generation is kept off the simulation tick. The generating side lives in [`crates/shared/src/generator.rs`](../crates/shared/src/generator.rs); the streaming and storage side in [`crates/server/src/world_server.rs`](../crates/server/src/world_server.rs), driven from [`crates/server/src/main.rs`](../crates/server/src/main.rs).
## Overview
Chunk residency is *reconciled* every tick against a **desired set**: the union of a cylinder of chunks around every player anchor. Chunks inside the desired set are made resident; chunks outside it are evicted. Generation of a missing chunk is expensive, so it is performed on a dedicated worker-thread pool rather than inline on the tick. Reconciliation is therefore non-blocking: each pass *drains* whatever chunks the pool has finished, *evicts* what is no longer wanted, and *dispatches* what is still missing, without ever waiting for a chunk to be generated.
## Desired set
`cylinder_chunks(center, radius, out)` inserts every chunk position within the streaming cylinder around `center` into `out`. The shape is a disc in XZ (`dx² + dz² ≤ radius²`) extruded vertically to `±radius/2`, reflecting the fact that horizontal view distance exceeds vertical.
The ECS streaming system `stream_chunks` (in `main.rs`) queries every entity carrying `Player`, `Position`, and `ViewDistance`, and unions each anchor's cylinder into one `HashSet<ChunkPos>`. Because the sets are unioned, overlapping cylinders deduplicate automatically and a chunk is evicted only when *no* player wants it. See [Multiplayer](#multiplayer) below.
## The worker pool
`ServerWorld::new` builds the pool once. Its structure:
- **Generator sharing:** the `VoxelGenerator` is wrapped in an `Arc` and a handle is cloned into every worker. `generate_chunk(&self)` is read-only, so no `Mutex` is required; the workers share one immutable generator.
- **Job channel (main → workers):** a `crossbeam-channel` carrying `ChunkPos`. `crossbeam-channel` is used rather than `std::sync::mpsc` because the pool needs **multiple consumers**: every worker clones the `Receiver` and pulls from the one shared queue, and each job is delivered to exactly one worker. `std::sync::mpsc` permits only a single consumer.
- **Result channel (workers → main):** a `crossbeam-channel` carrying `(ChunkPos, Chunk)`. Each worker clones the `Sender`; the main thread holds the single `Receiver`.
- **Worker loop:** each worker blocks on `job_rx.recv()`, generates the chunk, and sends `(pos, chunk)` back. A blocking `recv` on a worker thread is acceptable because it is not the simulation thread.
### Channel disconnection and shutdown
A `crossbeam-channel` reports disconnection (its `recv` returns `Err`) only once *all* senders — or, symmetrically, all receivers — have been dropped. After the spawn loop, the template `job_rx` and `result_tx` that were cloned from are dropped immediately. Retaining either would keep its channel open forever: workers would never observe job-channel shutdown, and the main thread would never observe the result channel closing. Worker `JoinHandle`s are retained on `ServerWorld` for a future graceful-stop path that drops `job_tx` and joins the threads; the process currently relies on OS teardown at exit.
## Reconcile: drain → evict → dispatch
`ServerWorld::reconcile(&mut self, desired)` runs three non-blocking phases per pass:
1. **Drain.** `result_rx.try_recv()` is pulled in a loop until empty (`try_recv` never blocks). Each returned position is removed from `in_flight`. A returned chunk is inserted into the resident map **only if it is still in `desired`** — see [the eviction race](#the-eviction-race).
2. **Evict.** Resident chunks absent from `desired` are removed. In-flight chunks that are no longer wanted need no handling here; the drain guard discards them when they arrive.
3. **Dispatch.** For every position in `desired` that is neither resident nor already in `in_flight`, the position is inserted into `in_flight` and sent on `job_tx`. The `in_flight` set is what prevents the same position being re-queued on every pass while a worker is still generating it.
`in_flight` therefore tracks positions dispatched but not yet returned, and is the single source of truth for "work outstanding."
### The eviction race
Between a chunk being dispatched and the worker returning it, the anchor may move so that the chunk is no longer wanted. Without a guard, the drain phase would insert the now-unwanted chunk, resurrecting a chunk that the evict phase had already discarded (or would never be asked to discard, since it was never resident). The guard in phase 1 — insert only if `desired.contains(&pos)` — makes a late arrival harmless: an unwanted chunk is dropped on arrival rather than made resident.
## Startup loading gate
Startup reuses the *same* worker pool and schedule; there is no separate synchronous loading path. Before granting player control, `main` runs the streaming schedule in a loop and polls `ServerWorld::streaming_idle()` (true when `in_flight` is empty). Once the initial region has at least one resident chunk and no work in flight, the region is ready. Waiting here is acceptable because no gameplay is running yet. During play the same reconcile runs every tick but is **never** waited on. A loading progress fraction is available as `resident / (resident + in_flight)`.
## Multiplayer
No per-player streaming pipeline exists. Every player anchor's cylinder is unioned into one desired set, reconciled against one chunk store served by one worker pool. A player joining or leaving is simply an entity entering or leaving the ECS query; it requires no streaming-specific code. The only per-player concern is the loading gate, which for a joining player checks that player's cylinder against the resident set rather than the global set. Backpressure and fairness across players (a bounded job channel, nearest-first priority) are shared-pipeline concerns deferred for later.
## Level of detail
Each job is currently a full-detail (LOD0) chunk. When LOD is introduced, the job payload grows from `ChunkPos` to `(ChunkPos, Lod)`; the worker-pool plumbing is LOD-agnostic and does not change.
## Testing
The pure cylinder math and the async reconcile behaviour are unit-tested in `world_server.rs`:
- `cylinder_chunks` symmetry, boundary inclusion, and translation invariance.
- `reconcile_converges_over_multiple_passes`: an initial pass dispatches work and leaves nothing resident; repeated passes drain the pool until every desired position is resident.
- `evicted_chunk_is_not_repopulated_on_arrival`: a dispatched chunk that stops being wanted is discarded on arrival and never becomes resident. This test is timing-independent because every pass after dispatch reconciles against an empty desired set.