diff --git a/.github/workflows/pr-benchmark.yml b/.github/workflows/pr-benchmark.yml index 14e9780..b035fbf 100644 --- a/.github/workflows/pr-benchmark.yml +++ b/.github/workflows/pr-benchmark.yml @@ -65,7 +65,7 @@ jobs: seconds=$((duration % 60)) peak_mem=${{ steps.benchmark.outputs.peak_memory }} - baseline_time=69 + baseline_time=30 diff=$((duration - baseline_time)) abs_diff=${diff#-} diff --git a/.gitignore b/.gitignore index 3ddeeaa..856457c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /wiki +*.mcworld # Environment files .env diff --git a/Cargo.lock b/Cargo.lock index e3c0840..a845fe0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "once_cell", "version_check", "zerocopy", @@ -185,6 +185,9 @@ name = "arnis" version = "2.3.1" dependencies = [ "base64 0.22.1", + "bedrockrs_level", + "bedrockrs_shared", + "byteorder", "clap", "colored", "dirs", @@ -198,6 +201,7 @@ dependencies = [ "indicatif", "itertools 0.14.0", "log", + "nbtx", "once_cell", "rand 0.8.5", "rayon", @@ -212,7 +216,9 @@ dependencies = [ "tauri-plugin-shell", "tempfile", "tokio", + "vek", "windows", + "zip", ] [[package]] @@ -298,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" dependencies = [ "async-lock", - "cfg-if", + "cfg-if 1.0.0", "concurrent-queue", "futures-io", "futures-lite", @@ -344,7 +350,7 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if", + "cfg-if 1.0.0", "event-listener", "futures-lite", "rustix 0.38.42", @@ -371,7 +377,7 @@ dependencies = [ "async-io", "async-lock", "atomic-waker", - "cfg-if", + "cfg-if 1.0.0", "futures-core", "futures-io", "rustix 0.38.42", @@ -467,6 +473,73 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bedrockrs_core" +version = "0.1.0" +source = "git+https://github.com/bedrock-crustaceans/bedrock-rs#7ef268b0a18373cf413ff53af99f85b3a327b8ec" + +[[package]] +name = "bedrockrs_level" +version = "0.1.0" +source = "git+https://github.com/bedrock-crustaceans/bedrock-rs#7ef268b0a18373cf413ff53af99f85b3a327b8ec" +dependencies = [ + "bedrockrs_core", + "bedrockrs_shared", + "bytemuck", + "byteorder", + "concat-idents", + "len-trait", + "miniz_oxide", + "nbtx", + "rusty-leveldb", + "serde", + "thiserror 1.0.69", + "uuid", + "vek", +] + +[[package]] +name = "bedrockrs_macros" +version = "0.1.0" +source = "git+https://github.com/bedrock-crustaceans/bedrock-rs#7ef268b0a18373cf413ff53af99f85b3a327b8ec" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.95", +] + +[[package]] +name = "bedrockrs_proto_core" +version = "0.1.0" +source = "git+https://github.com/bedrock-crustaceans/bedrock-rs#7ef268b0a18373cf413ff53af99f85b3a327b8ec" +dependencies = [ + "base64 0.22.1", + "bedrockrs_macros", + "byteorder", + "jsonwebtoken", + "nbtx", + "paste", + "seq-macro", + "serde_json", + "thiserror 2.0.9", + "uuid", + "varint-rs", + "vek", + "xuid", +] + +[[package]] +name = "bedrockrs_shared" +version = "0.1.0" +source = "git+https://github.com/bedrock-crustaceans/bedrock-rs#7ef268b0a18373cf413ff53af99f85b3a327b8ec" +dependencies = [ + "bedrockrs_macros", + "bedrockrs_proto_core", + "byteorder", + "log", + "varint-rs", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -637,9 +710,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.21.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" [[package]] name = "byteorder" @@ -767,6 +840,12 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.0" @@ -863,6 +942,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "concat-idents" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f76990911f2267d837d9d0ad060aa63aaad170af40904b29461734c339030d4d" +dependencies = [ + "quote", + "syn 2.0.95", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -960,13 +1049,28 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -1296,7 +1400,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -1352,6 +1456,17 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + [[package]] name = "errno" version = "0.3.10" @@ -1362,6 +1477,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "event-listener" version = "5.3.1" @@ -1470,7 +1595,7 @@ version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "libredox", "windows-sys 0.59.0", @@ -1825,7 +1950,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "wasi 0.9.0+wasi-snapshot-preview1", ] @@ -1836,9 +1961,11 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1847,7 +1974,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", @@ -2036,7 +2163,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "crunchy", ] @@ -2550,6 +2677,12 @@ dependencies = [ "cfb", ] +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2655,7 +2788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", - "cfg-if", + "cfg-if 1.0.0", "combine", "jni-sys", "log", @@ -2717,6 +2850,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2752,6 +2900,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +[[package]] +name = "len-trait" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723558ab8acaa07cb831b424cd164b587ddc1648b34748a30953c404e9a4a65b" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2798,7 +2955,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "winapi", ] @@ -2924,7 +3081,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "rayon", ] @@ -3014,6 +3171,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nbtx" +version = "0.1.0" +source = "git+https://github.com/bedrock-crustaceans/nbtx#551c38ac74f2e68a07d3dbdd354faac0c0ac966e" +dependencies = [ + "byteorder", + "paste", + "serde", + "thiserror 1.0.69", + "varint-rs", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3057,7 +3226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.6.0", - "cfg-if", + "cfg-if 1.0.0", "cfg_aliases", "libc", "memoffset", @@ -3483,7 +3652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ "bitflags 2.6.0", - "cfg-if", + "cfg-if 1.0.0", "foreign-types 0.3.2", "libc", "once_cell", @@ -3593,7 +3762,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "libc", "redox_syscall", "smallvec", @@ -3612,6 +3781,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3813,7 +3992,7 @@ version = "3.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "concurrent-queue", "hermit-abi", "pin-project-lite", @@ -4137,7 +4316,7 @@ dependencies = [ "av1-grain", "bitstream-io", "built", - "cfg-if", + "cfg-if 1.0.0", "interpolate_name", "itertools 0.12.1", "libc", @@ -4343,7 +4522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.0", "getrandom 0.2.15", "libc", "untrusted", @@ -4428,7 +4607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags 2.6.0", - "errno", + "errno 0.3.10", "libc", "linux-raw-sys 0.4.14", "windows-sys 0.59.0", @@ -4441,7 +4620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags 2.6.0", - "errno", + "errno 0.3.10", "libc", "linux-raw-sys 0.9.4", "windows-sys 0.59.0", @@ -4492,6 +4671,20 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "rusty-leveldb" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c48d2f060dd1286adc9c3d179cb5af1292a9d2fcf291abcfe056023fc1977b44" +dependencies = [ + "crc", + "errno 0.2.8", + "fs2", + "integer-encoding", + "rand 0.8.5", + "snap", +] + [[package]] name = "ryu" version = "1.0.18" @@ -4612,6 +4805,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -4794,7 +4993,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "cpufeatures", "digest", ] @@ -4845,6 +5044,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.9", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -4866,6 +5077,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "socket2" version = "0.5.8" @@ -5813,7 +6030,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "static_assertions", ] @@ -5992,12 +6209,31 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" +[[package]] +name = "varint-rs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vek" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25215c4675beead435b254fc510932ff7f519cbc585b1b9fe2539ee9f20ca331" +dependencies = [ + "approx", + "num-integer", + "num-traits", + "rustc_version", + "serde", +] + [[package]] name = "version-compare" version = "0.2.0" @@ -6076,7 +6312,7 @@ version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -6102,7 +6338,7 @@ version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "js-sys", "once_cell", "wasm-bindgen", @@ -6762,7 +6998,7 @@ version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "windows-sys 0.59.0", ] @@ -6872,6 +7108,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xuid" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cc57e8e1819a2c06319a1387a6f1b0f8148a0221d17694a43ae63b60f407f0" + [[package]] name = "yoke" version = "0.7.5" @@ -7029,6 +7271,18 @@ dependencies = [ "syn 2.0.95", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index a1b3a03..2e51a39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,15 @@ overflow-checks = true [features] default = ["gui"] -gui = ["tauri", "tauri-plugin-log", "tauri-plugin-shell", "tokio", "rfd", "dirs", "tauri-build"] +gui = ["tauri", "tauri-plugin-log", "tauri-plugin-shell", "tokio", "rfd", "dirs", "tauri-build", "bedrock"] +bedrock = ["bedrockrs_level", "bedrockrs_shared", "nbtx", "zip", "byteorder", "vek"] [build-dependencies] tauri-build = {version = "2", optional = true} [dependencies] base64 = "0.22.1" +byteorder = { version = "1.5", optional = true } clap = { version = "4.5", features = ["derive", "env"] } colored = "3.0.0" dirs = {version = "6.0.0", optional = true } @@ -46,6 +48,11 @@ tauri = { version = "2", optional = true } tauri-plugin-log = { version = "2.6.0", optional = true } tauri-plugin-shell = { version = "2", optional = true } tokio = { version = "1.48.0", features = ["full"], optional = true } +bedrockrs_level = { git = "https://github.com/bedrock-crustaceans/bedrock-rs", package = "bedrockrs_level", optional = true } +bedrockrs_shared = { git = "https://github.com/bedrock-crustaceans/bedrock-rs", package = "bedrockrs_shared", optional = true } +nbtx = { git = "https://github.com/bedrock-crustaceans/nbtx", optional = true } +vek = { version = "0.17", optional = true } +zip = { version = "0.6", default-features = false, features = ["deflate"], optional = true } [target.'cfg(windows)'.dependencies] windows = { version = "0.61.1", features = ["Win32_System_Console"] } diff --git a/README.md b/README.md index 52c5bc5..aa0bb2f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Arnis [](https://github.com/louis-e/arnis/actions) [](https://github.com/louis-e/arnis/releases) [](https://github.com/louis-e/arnis/releases) [](https://github.com/louis-e/arnis/releases) [](https://discord.gg/mA2g69Fhxq) -Arnis creates complex and accurate Minecraft Java Edition worlds that reflect real-world geography, topography, and architecture. +Arnis creates complex and accurate Minecraft Java Edition (1.17+) and Bedrock Edition worlds that reflect real-world geography, topography, and architecture. This free and open source project is designed to handle large-scale geographic data from the real world and generate detailed Minecraft worlds. The algorithm processes geospatial data from OpenStreetMap as well as elevation data to create an accurate Minecraft representation of terrain and architecture. Generate your hometown, big cities, and natural landscapes with ease! diff --git a/assets/minecraft/world_icon.jpeg b/assets/minecraft/world_icon.jpeg new file mode 100644 index 0000000..a741cf5 Binary files /dev/null and b/assets/minecraft/world_icon.jpeg differ diff --git a/src/bedrock_block_map.rs b/src/bedrock_block_map.rs new file mode 100644 index 0000000..b457aae --- /dev/null +++ b/src/bedrock_block_map.rs @@ -0,0 +1,849 @@ +//! Bedrock Block Mapping +//! +//! This module provides translation between the internal Block representation +//! and Bedrock Edition block format. Bedrock uses string identifiers with +//! state properties that differ slightly from Java Edition. + +use crate::block_definitions::Block; +use std::collections::HashMap; + +/// Represents a Bedrock block with its identifier and state properties. +#[derive(Debug, Clone)] +pub struct BedrockBlock { + /// The Bedrock block identifier (e.g., "minecraft:stone") + pub name: String, + /// Block state properties as key-value pairs + pub states: HashMap, +} + +/// Bedrock block state values can be strings, booleans, or integers. +#[derive(Debug, Clone)] +pub enum BedrockBlockStateValue { + String(String), + Bool(bool), + Int(i32), +} + +impl BedrockBlock { + /// Creates a simple block with no state properties. + pub fn simple(name: &str) -> Self { + Self { + name: format!("minecraft:{name}"), + states: HashMap::new(), + } + } + + /// Creates a block with state properties. + pub fn with_states(name: &str, states: Vec<(&str, BedrockBlockStateValue)>) -> Self { + let mut state_map = HashMap::new(); + for (key, value) in states { + state_map.insert(key.to_string(), value); + } + Self { + name: format!("minecraft:{name}"), + states: state_map, + } + } +} + +/// Converts an internal Block to a BedrockBlock representation. +/// +/// This function handles the mapping between Java Edition block names/properties +/// and their Bedrock Edition equivalents. Many blocks are identical, but some +/// require translation of property names or values. +pub fn to_bedrock_block(block: Block) -> BedrockBlock { + let java_name = block.name(); + + // Most blocks have the same name in both editions + // Handle special cases first, then fall back to direct mapping + match java_name { + // Grass block is just "grass_block" in both editions + "grass_block" => BedrockBlock::simple("grass_block"), + + // Short grass is just "short_grass" in Java but "tallgrass" in Bedrock + "short_grass" => BedrockBlock::with_states( + "tallgrass", + vec![( + "tall_grass_type", + BedrockBlockStateValue::String("tall".to_string()), + )], + ), + + // Tall grass needs height state + "tall_grass" => BedrockBlock::with_states( + "double_plant", + vec![( + "double_plant_type", + BedrockBlockStateValue::String("grass".to_string()), + )], + ), + + // Oak leaves with persistence + "oak_leaves" => BedrockBlock::with_states( + "leaves", + vec![ + ( + "old_leaf_type", + BedrockBlockStateValue::String("oak".to_string()), + ), + ("persistent_bit", BedrockBlockStateValue::Bool(true)), + ], + ), + + // Birch leaves with persistence + "birch_leaves" => BedrockBlock::with_states( + "leaves", + vec![ + ( + "old_leaf_type", + BedrockBlockStateValue::String("birch".to_string()), + ), + ("persistent_bit", BedrockBlockStateValue::Bool(true)), + ], + ), + + // Oak log with axis (default up_down) + "oak_log" => BedrockBlock::with_states( + "oak_log", + vec![( + "pillar_axis", + BedrockBlockStateValue::String("y".to_string()), + )], + ), + + // Birch log with axis + "birch_log" => BedrockBlock::with_states( + "birch_log", + vec![( + "pillar_axis", + BedrockBlockStateValue::String("y".to_string()), + )], + ), + + // Spruce log with axis + "spruce_log" => BedrockBlock::with_states( + "spruce_log", + vec![( + "pillar_axis", + BedrockBlockStateValue::String("y".to_string()), + )], + ), + + // Stone slab (bottom half by default) + "stone_slab" => BedrockBlock::with_states( + "stone_block_slab", + vec![ + ( + "stone_slab_type", + BedrockBlockStateValue::String("smooth_stone".to_string()), + ), + ("top_slot_bit", BedrockBlockStateValue::Bool(false)), + ], + ), + + // Stone brick slab + "stone_brick_slab" => BedrockBlock::with_states( + "stone_block_slab", + vec![ + ( + "stone_slab_type", + BedrockBlockStateValue::String("stone_brick".to_string()), + ), + ("top_slot_bit", BedrockBlockStateValue::Bool(false)), + ], + ), + + // Oak slab + "oak_slab" => BedrockBlock::with_states( + "wooden_slab", + vec![ + ( + "wood_type", + BedrockBlockStateValue::String("oak".to_string()), + ), + ("top_slot_bit", BedrockBlockStateValue::Bool(false)), + ], + ), + + // Water (flowing by default) + "water" => BedrockBlock::with_states( + "water", + vec![("liquid_depth", BedrockBlockStateValue::Int(0))], + ), + + // Rail with shape state + "rail" => BedrockBlock::with_states( + "rail", + vec![("rail_direction", BedrockBlockStateValue::Int(0))], + ), + + // Farmland with moisture + "farmland" => BedrockBlock::with_states( + "farmland", + vec![("moisturized_amount", BedrockBlockStateValue::Int(7))], + ), + + // Snow layer + "snow" => BedrockBlock::with_states( + "snow_layer", + vec![("height", BedrockBlockStateValue::Int(0))], + ), + + // Cobblestone wall + "cobblestone_wall" => BedrockBlock::with_states( + "cobblestone_wall", + vec![( + "wall_block_type", + BedrockBlockStateValue::String("cobblestone".to_string()), + )], + ), + + // Andesite wall + "andesite_wall" => BedrockBlock::with_states( + "cobblestone_wall", + vec![( + "wall_block_type", + BedrockBlockStateValue::String("andesite".to_string()), + )], + ), + + // Stone brick wall + "stone_brick_wall" => BedrockBlock::with_states( + "cobblestone_wall", + vec![( + "wall_block_type", + BedrockBlockStateValue::String("stone_brick".to_string()), + )], + ), + + // Flowers - poppy is just "red_flower" in Bedrock + "poppy" => BedrockBlock::with_states( + "red_flower", + vec![( + "flower_type", + BedrockBlockStateValue::String("poppy".to_string()), + )], + ), + + // Dandelion is "yellow_flower" in Bedrock + "dandelion" => BedrockBlock::simple("yellow_flower"), + + // Blue orchid + "blue_orchid" => BedrockBlock::with_states( + "red_flower", + vec![( + "flower_type", + BedrockBlockStateValue::String("orchid".to_string()), + )], + ), + + // Azure bluet + "azure_bluet" => BedrockBlock::with_states( + "red_flower", + vec![( + "flower_type", + BedrockBlockStateValue::String("houstonia".to_string()), + )], + ), + + // Concrete colors (Bedrock uses a single block with color state) + "white_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("white".to_string()))], + ), + "black_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("black".to_string()))], + ), + "gray_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("gray".to_string()))], + ), + "light_gray_concrete" => BedrockBlock::with_states( + "concrete", + vec![( + "color", + BedrockBlockStateValue::String("silver".to_string()), + )], + ), + "light_blue_concrete" => BedrockBlock::with_states( + "concrete", + vec![( + "color", + BedrockBlockStateValue::String("light_blue".to_string()), + )], + ), + "cyan_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("cyan".to_string()))], + ), + "blue_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("blue".to_string()))], + ), + "purple_concrete" => BedrockBlock::with_states( + "concrete", + vec![( + "color", + BedrockBlockStateValue::String("purple".to_string()), + )], + ), + "magenta_concrete" => BedrockBlock::with_states( + "concrete", + vec![( + "color", + BedrockBlockStateValue::String("magenta".to_string()), + )], + ), + "red_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("red".to_string()))], + ), + "orange_concrete" => BedrockBlock::with_states( + "concrete", + vec![( + "color", + BedrockBlockStateValue::String("orange".to_string()), + )], + ), + "yellow_concrete" => BedrockBlock::with_states( + "concrete", + vec![( + "color", + BedrockBlockStateValue::String("yellow".to_string()), + )], + ), + "lime_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("lime".to_string()))], + ), + "brown_concrete" => BedrockBlock::with_states( + "concrete", + vec![("color", BedrockBlockStateValue::String("brown".to_string()))], + ), + + // Terracotta colors + "white_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("white".to_string()))], + ), + "orange_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![( + "color", + BedrockBlockStateValue::String("orange".to_string()), + )], + ), + "yellow_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![( + "color", + BedrockBlockStateValue::String("yellow".to_string()), + )], + ), + "light_blue_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![( + "color", + BedrockBlockStateValue::String("light_blue".to_string()), + )], + ), + "blue_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("blue".to_string()))], + ), + "gray_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("gray".to_string()))], + ), + "green_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("green".to_string()))], + ), + "red_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("red".to_string()))], + ), + "brown_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("brown".to_string()))], + ), + "black_terracotta" => BedrockBlock::with_states( + "stained_hardened_clay", + vec![("color", BedrockBlockStateValue::String("black".to_string()))], + ), + // Plain terracotta + "terracotta" => BedrockBlock::simple("hardened_clay"), + + // Wool colors + "white_wool" => BedrockBlock::with_states( + "wool", + vec![("color", BedrockBlockStateValue::String("white".to_string()))], + ), + "red_wool" => BedrockBlock::with_states( + "wool", + vec![("color", BedrockBlockStateValue::String("red".to_string()))], + ), + "green_wool" => BedrockBlock::with_states( + "wool", + vec![("color", BedrockBlockStateValue::String("green".to_string()))], + ), + "brown_wool" => BedrockBlock::with_states( + "wool", + vec![("color", BedrockBlockStateValue::String("brown".to_string()))], + ), + "cyan_wool" => BedrockBlock::with_states( + "wool", + vec![("color", BedrockBlockStateValue::String("cyan".to_string()))], + ), + "yellow_wool" => BedrockBlock::with_states( + "wool", + vec![( + "color", + BedrockBlockStateValue::String("yellow".to_string()), + )], + ), + + // Carpets + "white_carpet" => BedrockBlock::with_states( + "carpet", + vec![("color", BedrockBlockStateValue::String("white".to_string()))], + ), + "red_carpet" => BedrockBlock::with_states( + "carpet", + vec![("color", BedrockBlockStateValue::String("red".to_string()))], + ), + + // Stained glass + "white_stained_glass" => BedrockBlock::with_states( + "stained_glass", + vec![("color", BedrockBlockStateValue::String("white".to_string()))], + ), + "gray_stained_glass" => BedrockBlock::with_states( + "stained_glass", + vec![("color", BedrockBlockStateValue::String("gray".to_string()))], + ), + "light_gray_stained_glass" => BedrockBlock::with_states( + "stained_glass", + vec![( + "color", + BedrockBlockStateValue::String("silver".to_string()), + )], + ), + "brown_stained_glass" => BedrockBlock::with_states( + "stained_glass", + vec![("color", BedrockBlockStateValue::String("brown".to_string()))], + ), + + // Planks - Bedrock uses single "planks" block with wood_type state + "oak_planks" => BedrockBlock::with_states( + "planks", + vec![( + "wood_type", + BedrockBlockStateValue::String("oak".to_string()), + )], + ), + "spruce_planks" => BedrockBlock::with_states( + "planks", + vec![( + "wood_type", + BedrockBlockStateValue::String("spruce".to_string()), + )], + ), + "birch_planks" => BedrockBlock::with_states( + "planks", + vec![( + "wood_type", + BedrockBlockStateValue::String("birch".to_string()), + )], + ), + "jungle_planks" => BedrockBlock::with_states( + "planks", + vec![( + "wood_type", + BedrockBlockStateValue::String("jungle".to_string()), + )], + ), + "acacia_planks" => BedrockBlock::with_states( + "planks", + vec![( + "wood_type", + BedrockBlockStateValue::String("acacia".to_string()), + )], + ), + "dark_oak_planks" => BedrockBlock::with_states( + "planks", + vec![( + "wood_type", + BedrockBlockStateValue::String("dark_oak".to_string()), + )], + ), + "crimson_planks" => BedrockBlock::simple("crimson_planks"), + "warped_planks" => BedrockBlock::simple("warped_planks"), + + // Stone variants + "stone" => BedrockBlock::simple("stone"), + "granite" => BedrockBlock::with_states( + "stone", + vec![( + "stone_type", + BedrockBlockStateValue::String("granite".to_string()), + )], + ), + "polished_granite" => BedrockBlock::with_states( + "stone", + vec![( + "stone_type", + BedrockBlockStateValue::String("granite_smooth".to_string()), + )], + ), + "diorite" => BedrockBlock::with_states( + "stone", + vec![( + "stone_type", + BedrockBlockStateValue::String("diorite".to_string()), + )], + ), + "polished_diorite" => BedrockBlock::with_states( + "stone", + vec![( + "stone_type", + BedrockBlockStateValue::String("diorite_smooth".to_string()), + )], + ), + "andesite" => BedrockBlock::with_states( + "stone", + vec![( + "stone_type", + BedrockBlockStateValue::String("andesite".to_string()), + )], + ), + "polished_andesite" => BedrockBlock::with_states( + "stone", + vec![( + "stone_type", + BedrockBlockStateValue::String("andesite_smooth".to_string()), + )], + ), + + // Blocks with different names in Bedrock + "bricks" => BedrockBlock::simple("brick_block"), + "end_stone_bricks" => BedrockBlock::simple("end_bricks"), + "nether_bricks" => BedrockBlock::simple("nether_brick"), + "red_nether_bricks" => BedrockBlock::simple("red_nether_brick"), + "snow_block" => BedrockBlock::simple("snow"), + "dirt_path" => BedrockBlock::simple("grass_path"), + "dead_bush" => BedrockBlock::simple("deadbush"), + "note_block" => BedrockBlock::simple("noteblock"), + + // Oak items mapped to dark_oak in Bedrock (or generic equivalents) + "oak_pressure_plate" => BedrockBlock::simple("wooden_pressure_plate"), + "oak_door" => BedrockBlock::simple("wooden_door"), + "oak_trapdoor" => BedrockBlock::simple("trapdoor"), + + // Bed (Bedrock uses single "bed" block with color state) + "red_bed" => BedrockBlock::with_states( + "bed", + vec![("color", BedrockBlockStateValue::String("red".to_string()))], + ), + + // Default: use the same name (works for many blocks) + // Log unmapped blocks to help identify missing mappings + _ => BedrockBlock::simple(java_name), + } +} + +/// Converts an internal Block with optional Java properties to a BedrockBlock. +/// +/// This function extends `to_bedrock_block` by also handling block-specific properties +/// like stair facing/shape, slab type, etc. Java property names and values are converted +/// to their Bedrock equivalents. +pub fn to_bedrock_block_with_properties( + block: Block, + java_properties: Option<&fastnbt::Value>, +) -> BedrockBlock { + let java_name = block.name(); + + // Extract Java properties as a map if present + let props_map = java_properties.and_then(|v| { + if let fastnbt::Value::Compound(map) = v { + Some(map) + } else { + None + } + }); + + // Handle stairs with facing/shape properties + if java_name.ends_with("_stairs") { + return convert_stairs(java_name, props_map); + } + + // Handle slabs with type property (top/bottom/double) + if java_name.ends_with("_slab") { + return convert_slab(java_name, props_map); + } + + // Handle logs with axis property + if java_name.ends_with("_log") || java_name.ends_with("_wood") { + return convert_log(java_name, props_map); + } + + // Fall back to basic conversion without properties + to_bedrock_block(block) +} + +/// Convert Java stair block to Bedrock format with proper orientation. +fn convert_stairs( + java_name: &str, + props: Option<&std::collections::HashMap>, +) -> BedrockBlock { + // Map Java stair names to Bedrock equivalents + let bedrock_name = match java_name { + "end_stone_brick_stairs" => "end_brick_stairs", + _ => java_name, // Most stairs have the same name + }; + + let mut states = HashMap::new(); + + // Convert facing: Java uses "north/south/east/west", Bedrock uses "weirdo_direction" (0-3) + // Bedrock: 0=east, 1=west, 2=south, 3=north + if let Some(props) = props { + if let Some(fastnbt::Value::String(facing)) = props.get("facing") { + let direction = match facing.as_str() { + "east" => 0, + "west" => 1, + "south" => 2, + "north" => 3, + _ => 0, + }; + states.insert( + "weirdo_direction".to_string(), + BedrockBlockStateValue::Int(direction), + ); + } + + // Convert half: Java uses "top/bottom", Bedrock uses "upside_down_bit" + if let Some(fastnbt::Value::String(half)) = props.get("half") { + let upside_down = half == "top"; + states.insert( + "upside_down_bit".to_string(), + BedrockBlockStateValue::Bool(upside_down), + ); + } + } + + // If no properties were set, use defaults + if states.is_empty() { + states.insert( + "weirdo_direction".to_string(), + BedrockBlockStateValue::Int(0), + ); + states.insert( + "upside_down_bit".to_string(), + BedrockBlockStateValue::Bool(false), + ); + } + + BedrockBlock { + name: format!("minecraft:{bedrock_name}"), + states, + } +} + +/// Convert Java slab block to Bedrock format with proper type. +fn convert_slab( + java_name: &str, + props: Option<&std::collections::HashMap>, +) -> BedrockBlock { + let mut states = HashMap::new(); + + // Convert type: Java uses "top/bottom/double", Bedrock uses "top_slot_bit" + if let Some(props) = props { + if let Some(fastnbt::Value::String(slab_type)) = props.get("type") { + let top_slot = slab_type == "top"; + states.insert( + "top_slot_bit".to_string(), + BedrockBlockStateValue::Bool(top_slot), + ); + // Note: "double" slabs in Java become full blocks in Bedrock (different block ID) + } + } + + // Default to bottom if not specified + if !states.contains_key("top_slot_bit") { + states.insert( + "top_slot_bit".to_string(), + BedrockBlockStateValue::Bool(false), + ); + } + + // Handle special slab name mappings (same as in to_bedrock_block) + let bedrock_name = match java_name { + "stone_slab" => "stone_block_slab", + "stone_brick_slab" => "stone_block_slab", + "oak_slab" => "wooden_slab", + "spruce_slab" => "wooden_slab", + "birch_slab" => "wooden_slab", + "jungle_slab" => "wooden_slab", + "acacia_slab" => "wooden_slab", + "dark_oak_slab" => "wooden_slab", + _ => java_name, + }; + + // Add wood_type for wooden slabs + if bedrock_name == "wooden_slab" { + let wood_type = java_name.trim_end_matches("_slab"); + states.insert( + "wood_type".to_string(), + BedrockBlockStateValue::String(wood_type.to_string()), + ); + } + + // Add stone_slab_type for stone slabs + if bedrock_name == "stone_block_slab" { + let slab_type = if java_name == "stone_brick_slab" { + "stone_brick" + } else { + "stone" + }; + states.insert( + "stone_slab_type".to_string(), + BedrockBlockStateValue::String(slab_type.to_string()), + ); + } + + BedrockBlock { + name: format!("minecraft:{bedrock_name}"), + states, + } +} + +/// Convert Java log/wood block to Bedrock format with proper axis. +fn convert_log( + java_name: &str, + props: Option<&std::collections::HashMap>, +) -> BedrockBlock { + let bedrock_name = java_name; + let mut states = HashMap::new(); + + // Convert axis: Java uses "x/y/z", Bedrock uses "pillar_axis" + if let Some(props) = props { + if let Some(fastnbt::Value::String(axis)) = props.get("axis") { + states.insert( + "pillar_axis".to_string(), + BedrockBlockStateValue::String(axis.clone()), + ); + } + } + + // Default to y-axis if not specified + if states.is_empty() { + states.insert( + "pillar_axis".to_string(), + BedrockBlockStateValue::String("y".to_string()), + ); + } + + BedrockBlock { + name: format!("minecraft:{bedrock_name}"), + states, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::block_definitions::{AIR, GRASS_BLOCK, STONE}; + + #[test] + fn test_simple_blocks() { + let bedrock = to_bedrock_block(STONE); + assert_eq!(bedrock.name, "minecraft:stone"); + assert!(bedrock.states.is_empty()); + + let bedrock = to_bedrock_block(AIR); + assert_eq!(bedrock.name, "minecraft:air"); + } + + #[test] + fn test_grass_block() { + let bedrock = to_bedrock_block(GRASS_BLOCK); + assert_eq!(bedrock.name, "minecraft:grass_block"); + } + + #[test] + fn test_colored_blocks() { + use crate::block_definitions::WHITE_CONCRETE; + let bedrock = to_bedrock_block(WHITE_CONCRETE); + assert_eq!(bedrock.name, "minecraft:concrete"); + assert!(matches!( + bedrock.states.get("color"), + Some(BedrockBlockStateValue::String(s)) if s == "white" + )); + } + + #[test] + fn test_stairs_with_properties() { + use crate::block_definitions::OAK_STAIRS; + use std::collections::HashMap as StdHashMap; + + // Create Java properties for a south-facing stair + let mut props = StdHashMap::new(); + props.insert( + "facing".to_string(), + fastnbt::Value::String("south".to_string()), + ); + props.insert( + "half".to_string(), + fastnbt::Value::String("bottom".to_string()), + ); + let java_props = fastnbt::Value::Compound(props); + + let bedrock = to_bedrock_block_with_properties(OAK_STAIRS, Some(&java_props)); + assert_eq!(bedrock.name, "minecraft:oak_stairs"); + + // Check weirdo_direction is set correctly (south = 2) + assert!(matches!( + bedrock.states.get("weirdo_direction"), + Some(BedrockBlockStateValue::Int(2)) + )); + + // Check upside_down_bit is false for bottom half + assert!(matches!( + bedrock.states.get("upside_down_bit"), + Some(BedrockBlockStateValue::Bool(false)) + )); + } + + #[test] + fn test_stairs_upside_down() { + use crate::block_definitions::STONE_BRICK_STAIRS; + use std::collections::HashMap as StdHashMap; + + // Create Java properties for an upside-down north-facing stair + let mut props = StdHashMap::new(); + props.insert( + "facing".to_string(), + fastnbt::Value::String("north".to_string()), + ); + props.insert( + "half".to_string(), + fastnbt::Value::String("top".to_string()), + ); + let java_props = fastnbt::Value::Compound(props); + + let bedrock = to_bedrock_block_with_properties(STONE_BRICK_STAIRS, Some(&java_props)); + + // Check weirdo_direction is set correctly (north = 3) + assert!(matches!( + bedrock.states.get("weirdo_direction"), + Some(BedrockBlockStateValue::Int(3)) + )); + + // Check upside_down_bit is true for top half + assert!(matches!( + bedrock.states.get("upside_down_bit"), + Some(BedrockBlockStateValue::Bool(true)) + )); + } +} diff --git a/src/data_processing.rs b/src/data_processing.rs index 5e54470..c0d199e 100644 --- a/src/data_processing.rs +++ b/src/data_processing.rs @@ -6,15 +6,25 @@ use crate::element_processing::*; use crate::ground::Ground; use crate::map_renderer; use crate::osm_parser::ProcessedElement; -use crate::progress::{emit_gui_progress_update, emit_map_preview_ready}; +use crate::progress::{emit_gui_progress_update, emit_map_preview_ready, emit_open_mcworld_file}; #[cfg(feature = "gui")] use crate::telemetry::{send_log, LogLevel}; -use crate::world_editor::WorldEditor; +use crate::world_editor::{WorldEditor, WorldFormat}; use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; +use std::path::PathBuf; pub const MIN_Y: i32 = -64; +/// Generation options that can be passed separately from CLI Args +#[derive(Clone)] +pub struct GenerationOptions { + pub path: PathBuf, + pub format: WorldFormat, + pub level_name: Option, + pub spawn_point: Option<(i32, i32)>, +} + pub fn generate_world( elements: Vec, xzbbox: XZBBox, @@ -22,7 +32,35 @@ pub fn generate_world( ground: Ground, args: &Args, ) -> Result<(), String> { - let mut editor: WorldEditor = WorldEditor::new(args.path.clone(), &xzbbox, llbbox); + // Default to Java format when called from CLI + let options = GenerationOptions { + path: args.path.clone(), + format: WorldFormat::JavaAnvil, + level_name: None, + spawn_point: None, + }; + generate_world_with_options(elements, xzbbox, llbbox, ground, args, options).map(|_| ()) +} + +/// Generate world with explicit format options (used by GUI for Bedrock support) +pub fn generate_world_with_options( + elements: Vec, + xzbbox: XZBBox, + llbbox: LLBBox, + ground: Ground, + args: &Args, + options: GenerationOptions, +) -> Result { + let output_path = options.path.clone(); + let world_format = options.format; + let mut editor: WorldEditor = WorldEditor::new_with_format_and_name( + options.path, + &xzbbox, + llbbox, + options.format, + options.level_name, + options.spawn_point, + ); println!("{} Processing data...", "[4/7]".bold()); @@ -239,60 +277,76 @@ pub fn generate_world( // Update player spawn Y coordinate based on terrain height after generation #[cfg(feature = "gui")] - if let Some(spawn_coords) = &args.spawn_point { - use crate::gui::update_player_spawn_y_after_generation; - let bbox_string = format!( - "{},{},{},{}", - args.bbox.min().lng(), - args.bbox.min().lat(), - args.bbox.max().lng(), - args.bbox.max().lat() - ); + if world_format == WorldFormat::JavaAnvil { + if let Some(spawn_coords) = &args.spawn_point { + use crate::gui::update_player_spawn_y_after_generation; + let bbox_string = format!( + "{},{},{},{}", + args.bbox.min().lng(), + args.bbox.min().lat(), + args.bbox.max().lng(), + args.bbox.max().lat() + ); - if let Err(e) = update_player_spawn_y_after_generation( - &args.path, - Some(*spawn_coords), - bbox_string, - args.scale, - &ground, - ) { - let warning_msg = format!("Failed to update spawn point Y coordinate: {}", e); - eprintln!("Warning: {}", warning_msg); - #[cfg(feature = "gui")] - send_log(LogLevel::Warning, &warning_msg); + if let Err(e) = update_player_spawn_y_after_generation( + &args.path, + Some(*spawn_coords), + bbox_string, + args.scale, + &ground, + ) { + let warning_msg = format!("Failed to update spawn point Y coordinate: {}", e); + eprintln!("Warning: {}", warning_msg); + #[cfg(feature = "gui")] + send_log(LogLevel::Warning, &warning_msg); + } } } - emit_gui_progress_update(100.0, "Done! World generation completed."); - println!("{}", "Done! World generation completed.".green().bold()); + emit_gui_progress_update(99.0, "Finalizing world..."); - // Generate top-down map preview silently in background after completion - let world_path = args.path.clone(); - let bounds = ( - xzbbox.min_x(), - xzbbox.max_x(), - xzbbox.min_z(), - xzbbox.max_z(), - ); - std::thread::spawn(move || { - // Use catch_unwind to prevent any panic from affecting the application - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - map_renderer::render_world_map(&world_path, bounds.0, bounds.1, bounds.2, bounds.3) - })); - - match result { - Ok(Ok(_path)) => { - // Notify the GUI that the map preview is ready - emit_map_preview_ready(); - } - Ok(Err(e)) => { - eprintln!("Warning: Failed to generate map preview: {}", e); - } - Err(_) => { - eprintln!("Warning: Map preview generation panicked unexpectedly"); - } + // For Bedrock format, emit event to open the mcworld file + if world_format == WorldFormat::BedrockMcWorld { + if let Some(path_str) = output_path.to_str() { + emit_open_mcworld_file(path_str); } - }); + } - Ok(()) + // Generate top-down map preview silently in background after completion (Java only) + // Skip map preview for very large areas to avoid memory issues + const MAX_MAP_PREVIEW_AREA: i64 = 6400 * 6900; + let world_width = (xzbbox.max_x() - xzbbox.min_x()) as i64; + let world_height = (xzbbox.max_z() - xzbbox.min_z()) as i64; + let world_area = world_width * world_height; + + if world_format == WorldFormat::JavaAnvil && world_area <= MAX_MAP_PREVIEW_AREA { + let world_path = args.path.clone(); + let bounds = ( + xzbbox.min_x(), + xzbbox.max_x(), + xzbbox.min_z(), + xzbbox.max_z(), + ); + std::thread::spawn(move || { + // Use catch_unwind to prevent any panic from affecting the application + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + map_renderer::render_world_map(&world_path, bounds.0, bounds.1, bounds.2, bounds.3) + })); + + match result { + Ok(Ok(_path)) => { + // Notify the GUI that the map preview is ready + emit_map_preview_ready(); + } + Ok(Err(e)) => { + eprintln!("Warning: Failed to generate map preview: {}", e); + } + Err(_) => { + eprintln!("Warning: Map preview generation panicked unexpectedly"); + } + } + }); + } + + Ok(output_path) } diff --git a/src/elevation_data.rs b/src/elevation_data.rs index 488778c..d05bbcf 100644 --- a/src/elevation_data.rs +++ b/src/elevation_data.rs @@ -116,11 +116,6 @@ pub fn fetch_elevation_data( tile_path.display(), file_size ); - #[cfg(feature = "gui")] - send_log( - LogLevel::Warning, - "Cached tile appears to be too small. Refetching tile.", - ); // Remove the potentially corrupted file if let Err(remove_err) = std::fs::remove_file(&tile_path) { @@ -251,25 +246,26 @@ pub fn fetch_elevation_data( filter_elevation_outliers(&mut height_grid); // Calculate blur sigma based on grid resolution - // Reference points for tuning: - const SMALL_GRID_REF: f64 = 100.0; // Reference grid size - const SMALL_SIGMA_REF: f64 = 15.0; // Sigma for 100x100 grid - const LARGE_GRID_REF: f64 = 1000.0; // Reference grid size - const LARGE_SIGMA_REF: f64 = 7.0; // Sigma for 1000x1000 grid + // Use sqrt scaling to maintain consistent relative smoothing across different area sizes. + // This prevents larger generation areas from appearing noisier than smaller ones. + // Reference: 100x100 grid uses sigma=5 (5% relative blur) + const BASE_GRID_REF: f64 = 100.0; + const BASE_SIGMA_REF: f64 = 5.0; let grid_size: f64 = (grid_width.min(grid_height) as f64).max(1.0); - let sigma: f64 = if grid_size <= SMALL_GRID_REF { - // Linear scaling for small grids - SMALL_SIGMA_REF * (grid_size / SMALL_GRID_REF) - } else { - // Logarithmic scaling for larger grids - let ln_small: f64 = SMALL_GRID_REF.ln(); - let ln_large: f64 = LARGE_GRID_REF.ln(); - let log_grid_size: f64 = grid_size.ln(); - let t: f64 = (log_grid_size - ln_small) / (ln_large - ln_small); - SMALL_SIGMA_REF + t * (LARGE_SIGMA_REF - SMALL_SIGMA_REF) - }; + // Sqrt scaling provides a good balance: + // - 100x100: sigma = 5 (5% relative) + // - 500x500: sigma ≈ 11.2 (2.2% relative) + // - 1000x1000: sigma ≈ 15.8 (1.6% relative) + // This smooths terrain proportionally while preserving more detail. + let sigma: f64 = BASE_SIGMA_REF * (grid_size / BASE_GRID_REF).sqrt(); + + let blur_percentage: f64 = (sigma / grid_size) * 100.0; + eprintln!( + "Elevation blur: grid={}x{}, sigma={:.2}, blur_percentage={:.2}%", + grid_width, grid_height, sigma, blur_percentage + ); /* eprintln!( "Grid: {}x{}, Blur sigma: {:.2}", diff --git a/src/gui.rs b/src/gui.rs index 242c1e8..64d8964 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -2,14 +2,16 @@ use crate::args::Args; use crate::coordinate_system::cartesian::XZPoint; use crate::coordinate_system::geographic::{LLBBox, LLPoint}; use crate::coordinate_system::transformation::CoordTransformer; -use crate::data_processing; +use crate::data_processing::{self, GenerationOptions}; use crate::ground::{self, Ground}; use crate::map_transformation; use crate::osm_parser; -use crate::progress; +use crate::progress::{self, emit_gui_progress_update}; use crate::retrieve_data; use crate::telemetry::{self, send_log, LogLevel}; use crate::version_check; +use crate::world_editor::WorldFormat; +use colored::Colorize; use fastnbt::Value; use flate2::read::GzDecoder; use fs2::FileExt; @@ -60,6 +62,17 @@ impl Drop for SessionLock { } } +/// Gets the area name for a given bounding box using the center point +fn get_area_name_for_bedrock(bbox: &LLBBox) -> String { + let center_lat = (bbox.min().lat() + bbox.max().lat()) / 2.0; + let center_lon = (bbox.min().lng() + bbox.max().lng()) / 2.0; + + match retrieve_data::fetch_area_name(center_lat, center_lon) { + Ok(Some(name)) => name, + _ => "Unknown Location".to_string(), + } +} + pub fn run_gui() { // Launch the UI println!("Launching UI..."); @@ -101,7 +114,8 @@ pub fn run_gui() { gui_start_generation, gui_get_version, gui_check_for_updates, - gui_get_world_map_data + gui_get_world_map_data, + gui_show_in_folder ]) .setup(|app| { let app_handle = app.handle(); @@ -721,6 +735,57 @@ struct WorldMapData { max_lon: f64, } +/// Opens the file with default application (Windows) or shows in file explorer (macOS/Linux) +#[tauri::command] +fn gui_show_in_folder(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + // On Windows, try to open with default application (Minecraft Bedrock) + // If that fails, show in Explorer + if std::process::Command::new("cmd") + .args(["/C", "start", "", &path]) + .spawn() + .is_err() + { + std::process::Command::new("explorer") + .args(["/select,", &path]) + .spawn() + .map_err(|e| format!("Failed to open explorer: {}", e))?; + } + } + + #[cfg(target_os = "macos")] + { + // On macOS, just reveal in Finder + std::process::Command::new("open") + .args(["-R", &path]) + .spawn() + .map_err(|e| format!("Failed to open Finder: {}", e))?; + } + + #[cfg(target_os = "linux")] + { + // On Linux, just show in file manager + let path_parent = std::path::Path::new(&path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + + // Try nautilus with select first, then fall back to xdg-open on parent + if std::process::Command::new("nautilus") + .args(["--select", &path]) + .spawn() + .is_err() + { + let _ = std::process::Command::new("xdg-open") + .arg(&path_parent) + .spawn(); + } + } + + Ok(()) +} + #[tauri::command] #[allow(clippy::too_many_arguments)] #[allow(unused_variables)] @@ -738,6 +803,7 @@ fn gui_start_generation( is_new_world: bool, spawn_point: Option<(f64, f64)>, telemetry_consent: bool, + world_format: String, ) -> Result<(), String> { use progress::emit_gui_error; use LLBBox; @@ -749,7 +815,9 @@ fn gui_start_generation( telemetry::send_generation_click(); // If spawn point was chosen and the world is new, check and set the spawn point - if is_new_world && spawn_point.is_some() { + // Only update player position for Java worlds - Bedrock worlds don't have a pre-existing + // level.dat to modify (the spawn point will be set when the .mcworld is created) + if is_new_world && spawn_point.is_some() && world_format != "bedrock" { // Verify the spawn point is within bounds if let Some(coords) = spawn_point { let llbbox = match LLBBox::from_str(&bbox_text) { @@ -803,19 +871,73 @@ fn gui_start_generation( } }; - // Add localized name to the world if user generated a new world - let updated_world_path = if is_new_world { - add_localized_world_name(world_path, &bbox) + // Determine world format from UI selection + let world_format = if world_format == "bedrock" { + WorldFormat::BedrockMcWorld } else { - world_path + WorldFormat::JavaAnvil }; - // Create an Args instance with the chosen bounding box and world directory path + // Determine output path and level name based on format + let (generation_path, level_name) = match world_format { + WorldFormat::JavaAnvil => { + // Java: use the selected world path, add localized name if new + let updated_path = if is_new_world { + add_localized_world_name(world_path.clone(), &bbox) + } else { + world_path.clone() + }; + (updated_path, None) + } + WorldFormat::BedrockMcWorld => { + // Bedrock: generate .mcworld in current directory with location-based name + let area_name = get_area_name_for_bedrock(&bbox); + let filename = format!("Arnis {}.mcworld", area_name); + let lvl_name = format!("Arnis World: {}", area_name); + let output_path = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(filename); + (output_path, Some(lvl_name)) + } + }; + + // Calculate MC spawn coordinates from lat/lng if spawn point was provided + let mc_spawn_point: Option<(i32, i32)> = if let Some((lat, lng)) = spawn_point { + if let Ok(llpoint) = LLPoint::new(lat, lng) { + if let Ok((transformer, _)) = + CoordTransformer::llbbox_to_xzbbox(&bbox, world_scale) + { + let xzpoint = transformer.transform_point(llpoint); + Some((xzpoint.x, xzpoint.z)) + } else { + None + } + } else { + None + } + } else { + None + }; + + // Create generation options + let generation_options = GenerationOptions { + path: generation_path.clone(), + format: world_format, + level_name, + spawn_point: mc_spawn_point, + }; + + // Create an Args instance with the chosen bounding box + // Note: path is used for Java-specific features like spawn point update let args: Args = Args { bbox, file: None, save_json_file: None, - path: updated_world_path, + path: if world_format == WorldFormat::JavaAnvil { + generation_path + } else { + world_path + }, downloader: "requests".to_string(), scale: world_scale, ground_level, @@ -839,14 +961,19 @@ fn gui_start_generation( CoordTransformer::llbbox_to_xzbbox(&args.bbox, args.scale) .map_err(|e| format!("Failed to create coordinate transformer: {}", e))?; - let _ = data_processing::generate_world( + let _ = data_processing::generate_world_with_options( parsed_elements, xzbbox, args.bbox, ground, &args, + generation_options, ); - // Session lock will be automatically released when _session_lock goes out of scope + // Explicitly release session lock before showing Done message + // so Minecraft can open the world immediately + drop(_session_lock); + emit_gui_progress_update(100.0, "Done! World generation completed."); + println!("{}", "Done! World generation completed.".green().bold()); return Ok(()); } @@ -877,14 +1004,19 @@ fn gui_start_generation( &mut ground, ); - let _ = data_processing::generate_world( + let _ = data_processing::generate_world_with_options( parsed_elements, xzbbox, args.bbox, ground, &args, + generation_options.clone(), ); - // Session lock will be automatically released when _session_lock goes out of scope + // Explicitly release session lock before showing Done message + // so Minecraft can open the world immediately + drop(_session_lock); + emit_gui_progress_update(100.0, "Done! World generation completed."); + println!("{}", "Done! World generation completed.".green().bold()); Ok(()) } Err(e) => { diff --git a/src/gui/css/styles.css b/src/gui/css/styles.css index 864cd3e..7f28f01 100644 --- a/src/gui/css/styles.css +++ b/src/gui/css/styles.css @@ -222,6 +222,68 @@ button:hover { width: 100%; } +/* World Selection Container */ +.world-selection-container { + width: 100%; +} + +.choose-world-btn { + padding: 10px; + line-height: 1.2; + width: 100%; + border-radius: 8px 8px 0 0 !important; + margin-bottom: 0 !important; + box-shadow: none !important; +} + +/* World Format Toggle */ +.format-toggle-container { + display: flex; + width: 100%; + gap: 0; + margin-top: 0; +} + +.format-toggle-btn { + flex: 1; + padding: 10px; + font-size: 1em; + font-weight: 500; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.25s ease; + margin-top: 0; + border-radius: 0; +} + +.format-toggle-btn:first-child { + border-radius: 0 0 0 8px; +} + +.format-toggle-btn:last-child { + border-radius: 0 0 8px 0; +} + +.format-toggle-btn:not(.format-active) { + background-color: #3a3a3a; + color: #b0b0b0; +} + +.format-toggle-btn:not(.format-active):hover { + background-color: #4a4a4a; + color: #ffffff; +} + +.format-toggle-btn.format-active { + background-color: var(--primary-accent); + color: #0f0f0f; + font-weight: 600; +} + +.format-toggle-btn.format-active:hover { + background-color: var(--primary-accent-dark); +} + /* Customization Settings */ .modal { position: fixed; diff --git a/src/gui/index.html b/src/gui/index.html index a216db1..bcdd7ac 100644 --- a/src/gui/index.html +++ b/src/gui/index.html @@ -37,15 +37,26 @@ Select World - - - - Choose World - - - No world selected - - + + + + + Choose World + + + No world selected + + + + + + + Java + + + Bedrock + + diff --git a/src/gui/js/bbox.js b/src/gui/js/bbox.js index 6877638..eefaed9 100644 --- a/src/gui/js/bbox.js +++ b/src/gui/js/bbox.js @@ -529,7 +529,7 @@ $(document).ready(function () { failureCount++; // After a few failures, try HTTP fallback - if (failureCount >= 3 && !this._httpFallbackAttempted && theme.url.startsWith('https://')) { + if (failureCount >= 6 && !this._httpFallbackAttempted && theme.url.startsWith('https://')) { console.log('HTTPS tile loading failed, attempting HTTP fallback for', themeKey); this._httpFallbackAttempted = true; diff --git a/src/gui/js/license.js b/src/gui/js/license.js index 0265126..ed96061 100644 --- a/src/gui/js/license.js +++ b/src/gui/js/license.js @@ -24,6 +24,10 @@ export const licenseText = ` Elevation data derived from the AWS Terrain Tiles dataset. +bedrock-rs: +Bedrock Edition world format support uses the bedrock-rs library, licensed under the Apache License 2.0. + + Privacy Policy: If you consent to telemetry data collection, please review our Privacy Policy at: https://arnismc.com/privacypolicy.html. diff --git a/src/gui/js/main.js b/src/gui/js/main.js index 8f53bb2..6781c20 100644 --- a/src/gui/js/main.js +++ b/src/gui/js/main.js @@ -24,6 +24,7 @@ window.addEventListener("DOMContentLoaded", async () => { handleBboxInput(); const localization = await getLocalization(); await applyLocalization(localization); + updateFormatToggleUI(selectedWorldFormat); initFooter(); await checkForUpdates(); }); @@ -220,6 +221,17 @@ function setupProgressListener() { console.log("Map preview ready event received"); showWorldPreviewButton(); }); + + // Listen for open-mcworld-file event to show the generated Bedrock world in file explorer + window.__TAURI__.event.listen("open-mcworld-file", async (event) => { + const filePath = event.payload; + try { + // Use our custom command to show the file in the system file explorer + await invoke("gui_show_in_folder", { path: filePath }); + } catch (error) { + console.error("Failed to show mcworld file in folder:", error); + } + }); } function initSettings() { @@ -248,6 +260,9 @@ function initSettings() { sliderValue.textContent = parseFloat(slider.value).toFixed(2); }); + // World format toggle (Java/Bedrock) + initWorldFormatToggle(); + // Language selector const languageSelect = document.getElementById("language-select"); const availableOptions = Array.from(languageSelect.options).map(opt => opt.value); @@ -350,6 +365,72 @@ function initSettings() { window.closeLicense = closeLicense; } +// World format selection (Java/Bedrock) +let selectedWorldFormat = 'java'; // Default to Java + +function initWorldFormatToggle() { + // Load saved format preference + const savedFormat = localStorage.getItem('arnis-world-format'); + if (savedFormat && (savedFormat === 'java' || savedFormat === 'bedrock')) { + selectedWorldFormat = savedFormat; + } + + // Apply the saved selection to UI + updateFormatToggleUI(selectedWorldFormat); +} + +function setWorldFormat(format) { + if (format !== 'java' && format !== 'bedrock') return; + + selectedWorldFormat = format; + localStorage.setItem('arnis-world-format', format); + updateFormatToggleUI(format); +} + +function updateFormatToggleUI(format) { + const javaBtn = document.getElementById('format-java'); + const bedrockBtn = document.getElementById('format-bedrock'); + const chooseWorldBtn = document.getElementById('choose-world-btn'); + const selectedWorldText = document.getElementById('selected-world'); + + if (format === 'java') { + javaBtn.classList.add('format-active'); + bedrockBtn.classList.remove('format-active'); + // Enable Choose World button for Java + if (chooseWorldBtn) { + chooseWorldBtn.disabled = false; + chooseWorldBtn.style.opacity = '1'; + chooseWorldBtn.style.cursor = 'pointer'; + } + // Show default text (world was cleared when switching to Bedrock) + if (selectedWorldText) { + const noWorldText = window.localization?.no_world_selected || 'No world selected'; + selectedWorldText.textContent = noWorldText; + selectedWorldText.style.color = '#fecc44'; + } + } else { + javaBtn.classList.remove('format-active'); + bedrockBtn.classList.add('format-active'); + // Disable Choose World button for Bedrock and clear any selected world + if (chooseWorldBtn) { + chooseWorldBtn.disabled = true; + chooseWorldBtn.style.opacity = '0.5'; + chooseWorldBtn.style.cursor = 'not-allowed'; + } + // Clear world selection and show Bedrock info message + worldPath = ""; + isNewWorld = false; + if (selectedWorldText) { + const bedrockText = window.localization?.bedrock_use_java || 'Use Java to select worlds'; + selectedWorldText.textContent = bedrockText; + selectedWorldText.style.color = '#fecc44'; + } + } +} + +// Expose to window for onclick handlers +window.setWorldFormat = setWorldFormat; + // Telemetry consent (first run only) function initTelemetryConsent() { const key = 'telemetry-consent'; // values: 'true' | 'false' @@ -539,8 +620,8 @@ function normalizeLongitude(lon) { return ((lon + 180) % 360 + 360) % 360 - 180; } -const threshold1 = 30000000.00; -const threshold2 = 45000000.00; +const threshold1 = 44000000.00; // Yellow warning threshold (~6.2km x 7km) +const threshold2 = 85000000.00; // Red error threshold (~8.7km x 9.8km) let selectedBBox = ""; let mapSelectedBBox = ""; // Tracks bbox from map selection let customBBoxValid = false; // Tracks if custom input is valid @@ -678,7 +759,8 @@ async function startGeneration() { return; } - if (!worldPath || worldPath === "") { + // Only require world selection for Java format (Bedrock generates a new .mcworld file) + if (selectedWorldFormat === 'java' && (!worldPath || worldPath === "")) { const selectedWorld = document.getElementById('selected-world'); localizeElement(window.localization, { element: selectedWorld }, "select_minecraft_world_first"); selectedWorld.style.color = "#fa7878"; @@ -735,7 +817,8 @@ async function startGeneration() { fillgroundEnabled: fill_ground, isNewWorld: isNewWorld, spawnPoint: spawnPoint, - telemetryConsent: telemetryConsent || false + telemetryConsent: telemetryConsent || false, + worldFormat: selectedWorldFormat }); console.log("Generation process started."); diff --git a/src/gui/locales/ar.json b/src/gui/locales/ar.json index bdc073c..2f6500a 100644 --- a/src/gui/locales/ar.json +++ b/src/gui/locales/ar.json @@ -44,5 +44,6 @@ "mode_terrain_only": "تضاريس فقط", "interior": "توليد الداخلية", "roof": "توليد السقف", - "fillground": "ملء الأرض" + "fillground": "ملء الأرض", + "bedrock_use_java": "استخدم Java لاختيار العوالم" } diff --git a/src/gui/locales/de.json b/src/gui/locales/de.json index 2afe495..bf2842b 100644 --- a/src/gui/locales/de.json +++ b/src/gui/locales/de.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Nur Terrain", "interior": "Innenraum Generierung", "roof": "Dach Generierung", - "fillground": "Boden füllen" + "fillground": "Boden füllen", + "bedrock_use_java": "Java für Weltauswahl nutzen" } \ No newline at end of file diff --git a/src/gui/locales/en-US.json b/src/gui/locales/en-US.json index 8e90ce4..82b2f4d 100644 --- a/src/gui/locales/en-US.json +++ b/src/gui/locales/en-US.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Terrain only", "interior": "Interior Generation", "roof": "Roof Generation", - "fillground": "Fill Ground" + "fillground": "Fill Ground", + "bedrock_use_java": "Use Java to select worlds" } \ No newline at end of file diff --git a/src/gui/locales/es.json b/src/gui/locales/es.json index c6b7020..1f22ea8 100644 --- a/src/gui/locales/es.json +++ b/src/gui/locales/es.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Solo Terreno", "interior": "Generación Interior", "roof": "Generación de Tejado", - "fillground": "Rellenar Suelo" + "fillground": "Rellenar Suelo", + "bedrock_use_java": "Usa Java para elegir mundos" } \ No newline at end of file diff --git a/src/gui/locales/fi.json b/src/gui/locales/fi.json index 9afbdb4..de33ed0 100644 --- a/src/gui/locales/fi.json +++ b/src/gui/locales/fi.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Vain maasto", "interior": "Sisätilan luonti", "roof": "Katon luonti", - "fillground": "Täytä maa" + "fillground": "Täytä maa", + "bedrock_use_java": "Käytä Javaa maailmojen valintaan" } diff --git a/src/gui/locales/fr-FR.json b/src/gui/locales/fr-FR.json index 25e9f4d..11200bf 100644 --- a/src/gui/locales/fr-FR.json +++ b/src/gui/locales/fr-FR.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Terrain uniquement", "interior": "Génération d'intérieur", "roof": "Génération de toit", - "fillground": "Remplir le sol" + "fillground": "Remplir le sol", + "bedrock_use_java": "Utilisez Java pour les mondes" } diff --git a/src/gui/locales/hu.json b/src/gui/locales/hu.json index 8e9ef65..9571204 100644 --- a/src/gui/locales/hu.json +++ b/src/gui/locales/hu.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Csak terep", "interior": "Belső generálás", "roof": "Tető generálás", - "fillground": "Talaj feltöltése" + "fillground": "Talaj feltöltése", + "bedrock_use_java": "Java világválasztáshoz" } \ No newline at end of file diff --git a/src/gui/locales/ko.json b/src/gui/locales/ko.json index 2d75bdd..6b03d6e 100644 --- a/src/gui/locales/ko.json +++ b/src/gui/locales/ko.json @@ -44,5 +44,6 @@ "mode_terrain_only": "지형만", "interior": "내부 생성", "roof": "지붕 생성", - "fillground": "지면 채우기" + "fillground": "지면 채우기", + "bedrock_use_java": "Java로 세계 선택" } \ No newline at end of file diff --git a/src/gui/locales/lt.json b/src/gui/locales/lt.json index f80df5b..3397334 100644 --- a/src/gui/locales/lt.json +++ b/src/gui/locales/lt.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Tik reljefas", "interior": "Interjero generavimas", "roof": "Stogo generavimas", - "fillground": "Užpildyti pagrindą" + "fillground": "Užpildyti pagrindą", + "bedrock_use_java": "Naudok Java pasauliams" } \ No newline at end of file diff --git a/src/gui/locales/lv.json b/src/gui/locales/lv.json index 4fa5050..404301a 100644 --- a/src/gui/locales/lv.json +++ b/src/gui/locales/lv.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Tikai reljefs", "interior": "Interjera ģenerēšana", "roof": "Jumta ģenerēšana", - "fillground": "Aizpildīt zemi" + "fillground": "Aizpildīt zemi", + "bedrock_use_java": "Izmanto Java pasaulēm" } \ No newline at end of file diff --git a/src/gui/locales/pl.json b/src/gui/locales/pl.json index 4009a7d..7de74b9 100644 --- a/src/gui/locales/pl.json +++ b/src/gui/locales/pl.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Tylko teren", "interior": "Generowanie wnętrza", "roof": "Generowanie dachu", - "fillground": "Wypełnij podłoże" + "fillground": "Wypełnij podłoże", + "bedrock_use_java": "Użyj Java do wyboru światów" } \ No newline at end of file diff --git a/src/gui/locales/ru.json b/src/gui/locales/ru.json index 8a34dbb..642ca9d 100644 --- a/src/gui/locales/ru.json +++ b/src/gui/locales/ru.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Только Рельеф", "interior": "Генерация Интерьера", "roof": "Генерация Крыши", - "fillground": "Заполнить Землю" + "fillground": "Заполнить Землю", + "bedrock_use_java": "Используйте Java для миров" } diff --git a/src/gui/locales/sv.json b/src/gui/locales/sv.json index e36c126..9e75746 100644 --- a/src/gui/locales/sv.json +++ b/src/gui/locales/sv.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Endast terräng", "interior": "Interiörgenerering", "roof": "Takgenerering", - "fillground": "Fyll mark" + "fillground": "Fyll mark", + "bedrock_use_java": "Använd Java för världar" } \ No newline at end of file diff --git a/src/gui/locales/ua.json b/src/gui/locales/ua.json index f58eaa7..1a59c3d 100644 --- a/src/gui/locales/ua.json +++ b/src/gui/locales/ua.json @@ -44,5 +44,6 @@ "mode_terrain_only": "Тільки рельєф", "interior": "Генерація інтер'єру", "roof": "Генерація даху", - "fillground": "Заповнити землю" + "fillground": "Заповнити землю", + "bedrock_use_java": "Використовуй Java для світів" } \ No newline at end of file diff --git a/src/gui/locales/zh-CN.json b/src/gui/locales/zh-CN.json index 4fd9de3..d644226 100644 --- a/src/gui/locales/zh-CN.json +++ b/src/gui/locales/zh-CN.json @@ -44,5 +44,6 @@ "mode_terrain_only": "仅地形", "interior": "内部生成", "roof": "屋顶生成", - "fillground": "填充地面" + "fillground": "填充地面", + "bedrock_use_java": "使用Java选择世界" } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7551e6c..a65d3e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod args; +#[cfg(feature = "bedrock")] +mod bedrock_block_map; mod block_definitions; mod bresenham; mod clipping; @@ -38,6 +40,7 @@ mod progress { pub fn emit_gui_error(_message: &str) {} pub fn emit_gui_progress_update(_progress: f64, _message: &str) {} pub fn emit_map_preview_ready() {} + pub fn emit_open_mcworld_file(_path: &str) {} pub fn is_running_with_gui() -> bool { false } diff --git a/src/progress.rs b/src/progress.rs index 4fdd1f3..06bc3ca 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -65,3 +65,12 @@ pub fn emit_map_preview_ready() { } } } + +/// Emits an event to open the generated mcworld file +pub fn emit_open_mcworld_file(path: &str) { + if let Some(window) = get_main_window() { + if let Err(e) = window.emit("open-mcworld-file", path) { + eprintln!("Failed to emit open-mcworld-file event: {}", e); + } + } +} diff --git a/src/world_editor.rs b/src/world_editor.rs deleted file mode 100644 index 6e9409e..0000000 --- a/src/world_editor.rs +++ /dev/null @@ -1,1030 +0,0 @@ -use crate::block_definitions::*; -use crate::coordinate_system::cartesian::{XZBBox, XZPoint}; -use crate::coordinate_system::geographic::LLBBox; -use crate::ground::Ground; -use crate::progress::emit_gui_progress_update; -#[cfg(feature = "gui")] -use crate::telemetry::{send_log, LogLevel}; -use colored::Colorize; -use fastanvil::Region; -use fastnbt::{LongArray, Value}; -use fnv::FnvHashMap; -use indicatif::{ProgressBar, ProgressStyle}; -use rayon::prelude::*; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs::File; -use std::io::Write; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct Chunk { - sections: Vec, - x_pos: i32, - z_pos: i32, - #[serde(default)] - is_light_on: u8, - #[serde(flatten)] - other: FnvHashMap, -} - -#[derive(Serialize, Deserialize)] -struct Section { - block_states: Blockstates, - #[serde(rename = "Y")] - y: i8, - #[serde(flatten)] - other: FnvHashMap, -} - -#[derive(Serialize, Deserialize)] -struct Blockstates { - palette: Vec, - data: Option, - #[serde(flatten)] - other: FnvHashMap, -} - -#[derive(Serialize, Deserialize)] -struct PaletteItem { - #[serde(rename = "Name")] - name: String, - #[serde(rename = "Properties")] - properties: Option, -} - -struct SectionToModify { - blocks: [Block; 4096], - // Store properties for blocks that have them, indexed by the same index as blocks array - properties: FnvHashMap, -} - -impl SectionToModify { - fn get_block(&self, x: u8, y: u8, z: u8) -> Option { - let b = self.blocks[Self::index(x, y, z)]; - if b == AIR { - return None; - } - - Some(b) - } - - fn set_block(&mut self, x: u8, y: u8, z: u8, block: Block) { - self.blocks[Self::index(x, y, z)] = block; - } - - fn set_block_with_properties( - &mut self, - x: u8, - y: u8, - z: u8, - block_with_props: BlockWithProperties, - ) { - let index = Self::index(x, y, z); - self.blocks[index] = block_with_props.block; - - // Store properties if they exist - if let Some(props) = block_with_props.properties { - self.properties.insert(index, props); - } else { - // Remove any existing properties for this position - self.properties.remove(&index); - } - } - - fn index(x: u8, y: u8, z: u8) -> usize { - usize::from(y) % 16 * 256 + usize::from(z) * 16 + usize::from(x) - } - - fn to_section(&self, y: i8) -> Section { - // Create a map of unique block+properties combinations to palette indices - let mut unique_blocks: Vec<(Block, Option)> = Vec::new(); - let mut palette_lookup: FnvHashMap<(Block, Option), usize> = FnvHashMap::default(); - - // Build unique block combinations and lookup table - for (i, &block) in self.blocks.iter().enumerate() { - let properties = self.properties.get(&i).cloned(); - - // Create a key for the lookup (block + properties hash) - let props_key = properties.as_ref().map(|p| format!("{p:?}")); - let lookup_key = (block, props_key); - - if let std::collections::hash_map::Entry::Vacant(e) = palette_lookup.entry(lookup_key) { - let palette_index = unique_blocks.len(); - e.insert(palette_index); - unique_blocks.push((block, properties)); - } - } - - let mut bits_per_block = 4; // minimum allowed - while (1 << bits_per_block) < unique_blocks.len() { - bits_per_block += 1; - } - - let mut data = vec![]; - let mut cur = 0; - let mut cur_idx = 0; - - for (i, &block) in self.blocks.iter().enumerate() { - let properties = self.properties.get(&i).cloned(); - let props_key = properties.as_ref().map(|p| format!("{p:?}")); - let lookup_key = (block, props_key); - let p = palette_lookup[&lookup_key] as i64; - - if cur_idx + bits_per_block > 64 { - data.push(cur); - cur = 0; - cur_idx = 0; - } - - cur |= p << cur_idx; - cur_idx += bits_per_block; - } - - if cur_idx > 0 { - data.push(cur); - } - - let palette = unique_blocks - .iter() - .map(|(block, stored_props)| PaletteItem { - name: block.name().to_string(), - properties: stored_props.clone().or_else(|| block.properties()), - }) - .collect(); - - Section { - block_states: Blockstates { - palette, - data: Some(LongArray::new(data)), - other: FnvHashMap::default(), - }, - y, - other: FnvHashMap::default(), - } - } -} - -impl Default for SectionToModify { - fn default() -> Self { - Self { - blocks: [AIR; 4096], - properties: FnvHashMap::default(), - } - } -} - -#[derive(Default)] -struct ChunkToModify { - sections: FnvHashMap, - other: FnvHashMap, -} - -impl ChunkToModify { - fn get_block(&self, x: u8, y: i32, z: u8) -> Option { - let section_idx: i8 = (y >> 4).try_into().unwrap(); - - let section = self.sections.get(§ion_idx)?; - - section.get_block(x, (y & 15).try_into().unwrap(), z) - } - - fn set_block(&mut self, x: u8, y: i32, z: u8, block: Block) { - let section_idx: i8 = (y >> 4).try_into().unwrap(); - - let section = self.sections.entry(section_idx).or_default(); - - section.set_block(x, (y & 15).try_into().unwrap(), z, block); - } - - fn set_block_with_properties( - &mut self, - x: u8, - y: i32, - z: u8, - block_with_props: BlockWithProperties, - ) { - let section_idx: i8 = (y >> 4).try_into().unwrap(); - - let section = self.sections.entry(section_idx).or_default(); - - section.set_block_with_properties(x, (y & 15).try_into().unwrap(), z, block_with_props); - } - - fn sections(&self) -> impl Iterator + '_ { - self.sections.iter().map(|(y, s)| s.to_section(*y)) - } -} - -#[derive(Default)] -struct RegionToModify { - chunks: FnvHashMap<(i32, i32), ChunkToModify>, -} - -impl RegionToModify { - fn get_or_create_chunk(&mut self, x: i32, z: i32) -> &mut ChunkToModify { - self.chunks.entry((x, z)).or_default() - } - - fn get_chunk(&self, x: i32, z: i32) -> Option<&ChunkToModify> { - self.chunks.get(&(x, z)) - } -} - -#[derive(Default)] -struct WorldToModify { - regions: FnvHashMap<(i32, i32), RegionToModify>, -} - -impl WorldToModify { - fn get_or_create_region(&mut self, x: i32, z: i32) -> &mut RegionToModify { - self.regions.entry((x, z)).or_default() - } - - fn get_region(&self, x: i32, z: i32) -> Option<&RegionToModify> { - self.regions.get(&(x, z)) - } - - fn get_block(&self, x: i32, y: i32, z: i32) -> Option { - let chunk_x: i32 = x >> 4; - let chunk_z: i32 = z >> 4; - let region_x: i32 = chunk_x >> 5; - let region_z: i32 = chunk_z >> 5; - - let region: &RegionToModify = self.get_region(region_x, region_z)?; - let chunk: &ChunkToModify = region.get_chunk(chunk_x & 31, chunk_z & 31)?; - - chunk.get_block( - (x & 15).try_into().unwrap(), - y, - (z & 15).try_into().unwrap(), - ) - } - - fn set_block(&mut self, x: i32, y: i32, z: i32, block: Block) { - let chunk_x: i32 = x >> 4; - let chunk_z: i32 = z >> 4; - let region_x: i32 = chunk_x >> 5; - let region_z: i32 = chunk_z >> 5; - - let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z); - let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31); - - chunk.set_block( - (x & 15).try_into().unwrap(), - y, - (z & 15).try_into().unwrap(), - block, - ); - } - - fn set_block_with_properties( - &mut self, - x: i32, - y: i32, - z: i32, - block_with_props: BlockWithProperties, - ) { - let chunk_x: i32 = x >> 4; - let chunk_z: i32 = z >> 4; - let region_x: i32 = chunk_x >> 5; - let region_z: i32 = chunk_z >> 5; - - let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z); - let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31); - - chunk.set_block_with_properties( - (x & 15).try_into().unwrap(), - y, - (z & 15).try_into().unwrap(), - block_with_props, - ); - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct WorldMetadata { - min_mc_x: i32, - max_mc_x: i32, - min_mc_z: i32, - max_mc_z: i32, - - min_geo_lat: f64, - max_geo_lat: f64, - min_geo_lon: f64, - max_geo_lon: f64, -} - -// Notes for someone not familiar with lifetime parameter: -// The follwing is like a C++ template: -// template -// struct WorldEditor {const XZBBox& xzbbox;} -pub struct WorldEditor<'a> { - world_dir: PathBuf, - world: WorldToModify, - xzbbox: &'a XZBBox, - llbbox: LLBBox, - ground: Option>, -} - -// template -// impl for struct WorldEditor {...} -impl<'a> WorldEditor<'a> { - // Initializes the WorldEditor with the region directory and template region path. - pub fn new(world_dir: PathBuf, xzbbox: &'a XZBBox, llbbox: LLBBox) -> Self { - Self { - world_dir, - world: WorldToModify::default(), - xzbbox, - llbbox, - ground: None, - } - } - - /// Sets the ground reference for elevation-based block placement - pub fn set_ground(&mut self, ground: &Ground) { - self.ground = Some(Box::new(ground.clone())); - } - - /// Gets a reference to the ground data if available - pub fn get_ground(&self) -> Option<&Ground> { - self.ground.as_ref().map(|g| g.as_ref()) - } - - /// Calculate the absolute Y position from a ground-relative offset - #[inline(always)] - pub fn get_absolute_y(&self, x: i32, y_offset: i32, z: i32) -> i32 { - if let Some(ground) = &self.ground { - ground.level(XZPoint::new( - x - self.xzbbox.min_x(), - z - self.xzbbox.min_z(), - )) + y_offset - } else { - y_offset // If no ground reference, use y_offset as absolute Y - } - } - - /// Creates a region for the given region coordinates. - fn create_region(&self, region_x: i32, region_z: i32) -> Region { - let out_path = self - .world_dir - .join(format!("region/r.{}.{}.mca", region_x, region_z)); - - const REGION_TEMPLATE: &[u8] = include_bytes!("../assets/minecraft/region.template"); - - let mut region_file: File = File::options() - .read(true) - .write(true) - .create(true) - .truncate(true) - .open(&out_path) - .expect("Failed to open region file"); - - region_file - .write_all(REGION_TEMPLATE) - .expect("Could not write region template"); - - Region::from_stream(region_file).expect("Failed to load region") - } - - pub fn get_min_coords(&self) -> (i32, i32) { - (self.xzbbox.min_x(), self.xzbbox.min_z()) - } - - pub fn get_max_coords(&self) -> (i32, i32) { - (self.xzbbox.max_x(), self.xzbbox.max_z()) - } - - #[allow(unused)] - #[inline] - pub fn block_at(&self, x: i32, y: i32, z: i32) -> bool { - let absolute_y = self.get_absolute_y(x, y, z); - self.world.get_block(x, absolute_y, z).is_some() - } - - #[allow(clippy::too_many_arguments, dead_code)] - pub fn set_sign( - &mut self, - line1: String, - line2: String, - line3: String, - line4: String, - x: i32, - y: i32, - z: i32, - _rotation: i8, - ) { - let absolute_y = self.get_absolute_y(x, y, z); - let chunk_x = x >> 4; - let chunk_z = z >> 4; - let region_x = chunk_x >> 5; - let region_z = chunk_z >> 5; - - let mut block_entities = HashMap::new(); - - let messages = vec![ - Value::String(format!("\"{line1}\"")), - Value::String(format!("\"{line2}\"")), - Value::String(format!("\"{line3}\"")), - Value::String(format!("\"{line4}\"")), - ]; - - let mut text_data = HashMap::new(); - text_data.insert("messages".to_string(), Value::List(messages)); - text_data.insert("color".to_string(), Value::String("black".to_string())); - text_data.insert("has_glowing_text".to_string(), Value::Byte(0)); - - block_entities.insert("front_text".to_string(), Value::Compound(text_data)); - block_entities.insert( - "id".to_string(), - Value::String("minecraft:sign".to_string()), - ); - block_entities.insert("is_waxed".to_string(), Value::Byte(0)); - block_entities.insert("keepPacked".to_string(), Value::Byte(0)); - block_entities.insert("x".to_string(), Value::Int(x)); - block_entities.insert("y".to_string(), Value::Int(absolute_y)); - block_entities.insert("z".to_string(), Value::Int(z)); - - let region: &mut RegionToModify = self.world.get_or_create_region(region_x, region_z); - let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31); - - if let Some(chunk_data) = chunk.other.get_mut("block_entities") { - if let Value::List(entities) = chunk_data { - entities.push(Value::Compound(block_entities)); - } - } else { - chunk.other.insert( - "block_entities".to_string(), - Value::List(vec![Value::Compound(block_entities)]), - ); - } - - self.set_block(SIGN, x, y, z, None, None); - } - - /// Sets a block of the specified type at the given coordinates. - /// Y value is interpreted as an offset from ground level. - #[inline] - pub fn set_block( - &mut self, - block: Block, - x: i32, - y: i32, - z: i32, - override_whitelist: Option<&[Block]>, - override_blacklist: Option<&[Block]>, - ) { - // Check if coordinates are within bounds - if !self.xzbbox.contains(&XZPoint::new(x, z)) { - return; - } - - // Calculate the absolute Y coordinate based on ground level - let absolute_y = self.get_absolute_y(x, y, z); - - let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { - // Check against whitelist and blacklist - if let Some(whitelist) = override_whitelist { - whitelist - .iter() - .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) - } else if let Some(blacklist) = override_blacklist { - !blacklist - .iter() - .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) - } else { - false - } - } else { - true - }; - - if should_insert { - self.world.set_block(x, absolute_y, z, block); - } - } - - /// Sets a block of the specified type at the given coordinates with absolute Y value. - #[inline] - pub fn set_block_absolute( - &mut self, - block: Block, - x: i32, - absolute_y: i32, - z: i32, - override_whitelist: Option<&[Block]>, - override_blacklist: Option<&[Block]>, - ) { - // Check if coordinates are within bounds - if !self.xzbbox.contains(&XZPoint::new(x, z)) { - return; - } - - let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { - // Check against whitelist and blacklist - if let Some(whitelist) = override_whitelist { - whitelist - .iter() - .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) - } else if let Some(blacklist) = override_blacklist { - !blacklist - .iter() - .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) - } else { - false - } - } else { - true - }; - - if should_insert { - self.world.set_block(x, absolute_y, z, block); - } - } - - /// Sets a block with properties at the given coordinates with absolute Y value. - #[inline] - pub fn set_block_with_properties_absolute( - &mut self, - block_with_props: BlockWithProperties, - x: i32, - absolute_y: i32, - z: i32, - override_whitelist: Option<&[Block]>, - override_blacklist: Option<&[Block]>, - ) { - // Check if coordinates are within bounds - if !self.xzbbox.contains(&XZPoint::new(x, z)) { - return; - } - - let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { - // Check against whitelist and blacklist - if let Some(whitelist) = override_whitelist { - whitelist - .iter() - .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) - } else if let Some(blacklist) = override_blacklist { - !blacklist - .iter() - .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) - } else { - false - } - } else { - true - }; - - if should_insert { - self.world - .set_block_with_properties(x, absolute_y, z, block_with_props); - } - } - - /// Fills a cuboid area with the specified block between two coordinates. - #[allow(clippy::too_many_arguments)] - #[inline] - pub fn fill_blocks( - &mut self, - block: Block, - x1: i32, - y1: i32, - z1: i32, - x2: i32, - y2: i32, - z2: i32, - override_whitelist: Option<&[Block]>, - override_blacklist: Option<&[Block]>, - ) { - let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) }; - let (min_y, max_y) = if y1 < y2 { (y1, y2) } else { (y2, y1) }; - let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) }; - - for x in min_x..=max_x { - for y_offset in min_y..=max_y { - for z in min_z..=max_z { - self.set_block( - block, - x, - y_offset, - z, - override_whitelist, - override_blacklist, - ); - } - } - } - } - - /// Fills a cuboid area with the specified block between two coordinates using absolute Y values. - #[allow(clippy::too_many_arguments)] - #[inline] - pub fn fill_blocks_absolute( - &mut self, - block: Block, - x1: i32, - y1_absolute: i32, - z1: i32, - x2: i32, - y2_absolute: i32, - z2: i32, - override_whitelist: Option<&[Block]>, - override_blacklist: Option<&[Block]>, - ) { - let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) }; - let (min_y, max_y) = if y1_absolute < y2_absolute { - (y1_absolute, y2_absolute) - } else { - (y2_absolute, y1_absolute) - }; - let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) }; - - for x in min_x..=max_x { - for absolute_y in min_y..=max_y { - for z in min_z..=max_z { - self.set_block_absolute( - block, - x, - absolute_y, - z, - override_whitelist, - override_blacklist, - ); - } - } - } - } - - /// Checks for a block at the given coordinates. - #[inline] - pub fn check_for_block(&self, x: i32, y: i32, z: i32, whitelist: Option<&[Block]>) -> bool { - let absolute_y = self.get_absolute_y(x, y, z); - - // Retrieve the chunk modification map - if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { - if let Some(whitelist) = whitelist { - if whitelist - .iter() - .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) - { - return true; // Block is in the list - } - } - } - false - } - - /// Checks for a block at the given coordinates with absolute Y value. - #[allow(unused)] - pub fn check_for_block_absolute( - &self, - x: i32, - absolute_y: i32, - z: i32, - whitelist: Option<&[Block]>, - blacklist: Option<&[Block]>, - ) -> bool { - // Retrieve the chunk modification map - if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { - // Check against whitelist and blacklist - if let Some(whitelist) = whitelist { - if whitelist - .iter() - .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) - { - return true; // Block is in whitelist - } - return false; - } - if let Some(blacklist) = blacklist { - if blacklist - .iter() - .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) - { - return true; // Block is in blacklist - } - } - return whitelist.is_none() && blacklist.is_none(); - } - - false - } - - /// Checks if a block exists at the given coordinates with absolute Y value. - /// Unlike check_for_block_absolute, this doesn't filter by block type. - #[allow(unused)] - pub fn block_at_absolute(&self, x: i32, absolute_y: i32, z: i32) -> bool { - self.world.get_block(x, absolute_y, z).is_some() - } - - /// Helper function to create a base chunk with grass blocks at Y -62 - fn create_base_chunk(abs_chunk_x: i32, abs_chunk_z: i32) -> (Vec, bool) { - let mut chunk = ChunkToModify::default(); - - // Fill the bottom layer with grass blocks at Y -62 - for x in 0..16 { - for z in 0..16 { - chunk.set_block(x, -62, z, GRASS_BLOCK); - } - } - - // Prepare chunk data - let chunk_data = Chunk { - sections: chunk.sections().collect(), - x_pos: abs_chunk_x, - z_pos: abs_chunk_z, - is_light_on: 0, - other: chunk.other, - }; - - // Create the Level wrapper - let level_data = create_level_wrapper(&chunk_data); - - // Serialize the chunk with Level wrapper - let mut ser_buffer = Vec::with_capacity(8192); - fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap(); - - (ser_buffer, true) - } - - /// Saves all changes made to the world by writing modified chunks to the appropriate region files. - pub fn save(&mut self) { - println!("{} Saving world...", "[7/7]".bold()); - emit_gui_progress_update(90.0, "Saving world..."); - - // Save metadata with error handling - if let Err(e) = self.save_metadata() { - eprintln!("Failed to save world metadata: {}", e); - #[cfg(feature = "gui")] - send_log(LogLevel::Warning, "Failed to save world metadata."); - // Continue with world saving even if metadata fails - } - - let total_regions = self.world.regions.len() as u64; - let save_pb = ProgressBar::new(total_regions); - save_pb.set_style( - ProgressStyle::default_bar() - .template( - "{spinner:.green} [{elapsed_precise}] [{bar:45}] {pos}/{len} regions ({eta})", - ) - .unwrap() - .progress_chars("█▓░"), - ); - - let total_steps: f64 = 9.0; - let progress_increment_save: f64 = total_steps / total_regions as f64; - let current_progress = AtomicU64::new(900); - let regions_processed = AtomicU64::new(0); - - self.world - .regions - .par_iter() - .for_each(|((region_x, region_z), region_to_modify)| { - let mut region = self.create_region(*region_x, *region_z); - let mut ser_buffer = Vec::with_capacity(8192); - - for (&(chunk_x, chunk_z), chunk_to_modify) in ®ion_to_modify.chunks { - if !chunk_to_modify.sections.is_empty() || !chunk_to_modify.other.is_empty() { - // Read existing chunk data if it exists - let existing_data = region - .read_chunk(chunk_x as usize, chunk_z as usize) - .unwrap() - .unwrap_or_default(); - - // Parse existing chunk or create new one - let mut chunk: Chunk = if !existing_data.is_empty() { - fastnbt::from_bytes(&existing_data).unwrap() - } else { - Chunk { - sections: Vec::new(), - x_pos: chunk_x + (region_x * 32), - z_pos: chunk_z + (region_z * 32), - is_light_on: 0, - other: FnvHashMap::default(), - } - }; - - // Update sections while preserving existing data - let new_sections: Vec = chunk_to_modify.sections().collect(); - for new_section in new_sections { - if let Some(existing_section) = - chunk.sections.iter_mut().find(|s| s.y == new_section.y) - { - // Merge block states - existing_section.block_states.palette = - new_section.block_states.palette; - existing_section.block_states.data = new_section.block_states.data; - } else { - // Add new section if it doesn't exist - chunk.sections.push(new_section); - } - } - - // Preserve existing block entities and merge with new ones - if let Some(existing_entities) = chunk.other.get_mut("block_entities") { - if let Some(new_entities) = chunk_to_modify.other.get("block_entities") - { - if let (Value::List(existing), Value::List(new)) = - (existing_entities, new_entities) - { - // Remove old entities that are replaced by new ones - existing.retain(|e| { - if let Value::Compound(map) = e { - let (x, y, z) = get_entity_coords(map); - !new.iter().any(|new_e| { - if let Value::Compound(new_map) = new_e { - let (nx, ny, nz) = get_entity_coords(new_map); - x == nx && y == ny && z == nz - } else { - false - } - }) - } else { - true - } - }); - // Add new entities - existing.extend(new.clone()); - } - } - } else { - // If no existing entities, just add the new ones - if let Some(new_entities) = chunk_to_modify.other.get("block_entities") - { - chunk - .other - .insert("block_entities".to_string(), new_entities.clone()); - } - } - - // Update chunk coordinates and flags - chunk.x_pos = chunk_x + (region_x * 32); - chunk.z_pos = chunk_z + (region_z * 32); - - // Create Level wrapper and save - let level_data = create_level_wrapper(&chunk); - ser_buffer.clear(); - fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap(); - region - .write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer) - .unwrap(); - } - } - - // Second pass: ensure all chunks exist - for chunk_x in 0..32 { - for chunk_z in 0..32 { - let abs_chunk_x = chunk_x + (region_x * 32); - let abs_chunk_z = chunk_z + (region_z * 32); - - // Check if chunk exists in our modifications - let chunk_exists = - region_to_modify.chunks.contains_key(&(chunk_x, chunk_z)); - - // If chunk doesn't exist, create it with base layer - if !chunk_exists { - let (ser_buffer, _) = Self::create_base_chunk(abs_chunk_x, abs_chunk_z); - region - .write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer) - .unwrap(); - } - } - } - - // Update progress - let regions_done = regions_processed.fetch_add(1, Ordering::SeqCst); - let new_progress = (90.0 + (regions_done as f64 * progress_increment_save)) * 10.0; - let prev_progress = - current_progress.fetch_max(new_progress as u64, Ordering::SeqCst); - - if new_progress as u64 - prev_progress > 1 { - emit_gui_progress_update(new_progress / 10.0, "Saving world..."); - } - - save_pb.inc(1); - }); - - save_pb.finish(); - } - - fn save_metadata(&mut self) -> Result<(), Box> { - let metadata_path = self.world_dir.join("metadata.json"); - - let mut file = File::create(&metadata_path).map_err(|e| { - format!( - "Failed to create metadata file at {}: {}", - metadata_path.display(), - e - ) - })?; - - let metadata = WorldMetadata { - min_mc_x: self.xzbbox.min_x(), - max_mc_x: self.xzbbox.max_x(), - min_mc_z: self.xzbbox.min_z(), - max_mc_z: self.xzbbox.max_z(), - - min_geo_lat: self.llbbox.min().lat(), - max_geo_lat: self.llbbox.max().lat(), - min_geo_lon: self.llbbox.min().lng(), - max_geo_lon: self.llbbox.max().lng(), - }; - - let contents = serde_json::to_string(&metadata) - .map_err(|e| format!("Failed to serialize metadata to JSON: {}", e))?; - - write!(&mut file, "{}", contents) - .map_err(|e| format!("Failed to write metadata to file: {}", e))?; - - Ok(()) - } -} - -// Helper function to get entity coordinates -#[inline] -fn get_entity_coords(entity: &HashMap) -> (i32, i32, i32) { - let x = if let Value::Int(x) = entity.get("x").unwrap_or(&Value::Int(0)) { - *x - } else { - 0 - }; - let y = if let Value::Int(y) = entity.get("y").unwrap_or(&Value::Int(0)) { - *y - } else { - 0 - }; - let z = if let Value::Int(z) = entity.get("z").unwrap_or(&Value::Int(0)) { - *z - } else { - 0 - }; - (x, y, z) -} - -#[inline] -fn create_level_wrapper(chunk: &Chunk) -> HashMap { - HashMap::from([( - "Level".to_string(), - Value::Compound(HashMap::from([ - ("xPos".to_string(), Value::Int(chunk.x_pos)), - ("zPos".to_string(), Value::Int(chunk.z_pos)), - ( - "isLightOn".to_string(), - Value::Byte(i8::try_from(chunk.is_light_on).unwrap()), - ), - ( - "sections".to_string(), - Value::List( - chunk - .sections - .iter() - .map(|section| { - let mut block_states = HashMap::from([( - "palette".to_string(), - Value::List( - section - .block_states - .palette - .iter() - .map(|item| { - let mut palette_item = HashMap::from([( - "Name".to_string(), - Value::String(item.name.clone()), - )]); - if let Some(props) = &item.properties { - palette_item.insert( - "Properties".to_string(), - props.clone(), - ); - } - Value::Compound(palette_item) - }) - .collect(), - ), - )]); - - // only add the `data` attribute if it's non-empty - // some software (cough cough dynmap) chokes otherwise - if let Some(data) = §ion.block_states.data { - if !data.is_empty() { - block_states.insert( - "data".to_string(), - Value::LongArray(data.to_owned()), - ); - } - } - - Value::Compound(HashMap::from([ - ("Y".to_string(), Value::Byte(section.y)), - ("block_states".to_string(), Value::Compound(block_states)), - ])) - }) - .collect(), - ), - ), - ])), - )]) -} diff --git a/src/world_editor/bedrock.rs b/src/world_editor/bedrock.rs new file mode 100644 index 0000000..a30fdf4 --- /dev/null +++ b/src/world_editor/bedrock.rs @@ -0,0 +1,1070 @@ +//! Bedrock Edition .mcworld format world saving. +//! +//! This module handles saving worlds in the Bedrock Edition format, +//! producing .mcworld files that can be imported into Minecraft Bedrock. + +use super::common::{ChunkToModify, SectionToModify, WorldToModify}; +use super::WorldMetadata; +use crate::bedrock_block_map::{ + to_bedrock_block_with_properties, BedrockBlock, BedrockBlockStateValue, +}; +use crate::coordinate_system::cartesian::XZBBox; +use crate::coordinate_system::geographic::LLBBox; +use crate::ground::Ground; +use crate::progress::emit_gui_progress_update; + +use bedrockrs_level::level::db_interface::bedrock_key::ChunkKey; +use bedrockrs_level::level::db_interface::rusty::RustyDBInterface; +use bedrockrs_level::level::file_interface::RawWorldTrait; +use bedrockrs_shared::world::dimension::Dimension; +use byteorder::{LittleEndian, WriteBytesExt}; +use indicatif::{ProgressBar, ProgressStyle}; +use serde::Serialize; +use std::collections::HashMap as StdHashMap; +use std::fs::{self, File}; +use std::io::{Cursor, Write as IoWrite}; +use std::path::PathBuf; +use vek::Vec2; +use zip::write::FileOptions; +use zip::CompressionMethod; +use zip::ZipWriter; + +/// Error type for Bedrock world saving operations +#[derive(Debug)] +pub enum BedrockSaveError { + Io(std::io::Error), + Zip(zip::result::ZipError), + Serialization(serde_json::Error), + Database(String), + Nbt(String), +} + +impl std::fmt::Display for BedrockSaveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BedrockSaveError::Io(err) => { + write!(f, "I/O error while writing Bedrock world: {err}") + } + BedrockSaveError::Zip(err) => { + write!(f, "Failed to package Bedrock world archive: {err}") + } + BedrockSaveError::Serialization(err) => { + write!(f, "Failed to serialize Bedrock metadata: {err}") + } + BedrockSaveError::Database(err) => { + write!(f, "LevelDB error: {err}") + } + BedrockSaveError::Nbt(err) => { + write!(f, "NBT serialization error: {err}") + } + } + } +} + +impl std::error::Error for BedrockSaveError {} + +impl From for BedrockSaveError { + fn from(err: std::io::Error) -> Self { + BedrockSaveError::Io(err) + } +} + +impl From for BedrockSaveError { + fn from(err: zip::result::ZipError) -> Self { + BedrockSaveError::Zip(err) + } +} + +impl From for BedrockSaveError { + fn from(err: serde_json::Error) -> Self { + BedrockSaveError::Serialization(err) + } +} + +/// Metadata for Bedrock worlds +#[derive(Serialize)] +struct BedrockMetadata { + #[serde(flatten)] + world: WorldMetadata, + format: &'static str, + chunk_count: usize, +} + +/// Bedrock block state for NBT serialization +#[derive(Serialize)] +struct BedrockBlockState { + name: String, + states: StdHashMap, +} + +/// NBT-compatible value types for Bedrock block states +#[derive(Serialize)] +#[serde(untagged)] +enum BedrockNbtValue { + String(String), + Byte(i8), + Int(i32), +} + +impl From<&BedrockBlockStateValue> for BedrockNbtValue { + #[inline] + fn from(value: &BedrockBlockStateValue) -> Self { + match value { + BedrockBlockStateValue::String(s) => BedrockNbtValue::String(s.clone()), + BedrockBlockStateValue::Bool(b) => BedrockNbtValue::Byte(if *b { 1 } else { 0 }), + BedrockBlockStateValue::Int(i) => BedrockNbtValue::Int(*i), + } + } +} + +/// Writer for Bedrock Edition worlds +pub struct BedrockWriter { + output_dir: PathBuf, + level_name: String, + spawn_point: Option<(i32, i32)>, + ground: Option>, +} + +impl BedrockWriter { + /// Creates a new BedrockWriter + pub fn new( + output_path: PathBuf, + level_name: String, + spawn_point: Option<(i32, i32)>, + ground: Option>, + ) -> Self { + // If the path ends with .mcworld, use it as the final archive path + // and create a temp directory without that extension for working files + let output_dir = if output_path.extension().is_some_and(|ext| ext == "mcworld") { + output_path.with_extension("") + } else { + output_path + }; + + Self { + output_dir, + level_name, + spawn_point, + ground, + } + } + + /// Writes the world to disk + pub fn write_world( + &mut self, + world: &WorldToModify, + xzbbox: &XZBBox, + llbbox: &LLBBox, + ) -> Result<(), BedrockSaveError> { + self.prepare_output_dir()?; + self.write_level_name()?; + + emit_gui_progress_update(91.0, "Saving Bedrock world..."); + self.write_level_dat(xzbbox)?; + + emit_gui_progress_update(92.0, "Saving Bedrock world..."); + self.write_chunks_to_db(world)?; + + emit_gui_progress_update(97.0, "Saving Bedrock world..."); + self.write_metadata(world, xzbbox, llbbox)?; + + emit_gui_progress_update(98.0, "Saving Bedrock world..."); + self.package_mcworld()?; + + emit_gui_progress_update(99.0, "Saving Bedrock world..."); + self.cleanup_temp_dir()?; + Ok(()) + } + + fn prepare_output_dir(&self) -> Result<(), BedrockSaveError> { + // Remove existing output directory and mcworld file to avoid conflicts + if self.output_dir.exists() { + fs::remove_dir_all(&self.output_dir)?; + } + let mcworld_path = self.output_dir.with_extension("mcworld"); + if mcworld_path.exists() { + fs::remove_file(&mcworld_path)?; + } + + fs::create_dir_all(&self.output_dir)?; + // db directory will be created by LevelDB + Ok(()) + } + + fn write_level_name(&self) -> Result<(), BedrockSaveError> { + let levelname_path = self.output_dir.join("levelname.txt"); + fs::write(levelname_path, &self.level_name)?; + Ok(()) + } + + fn write_level_dat(&self, xzbbox: &XZBBox) -> Result<(), BedrockSaveError> { + // Create a complete level.dat for Bedrock with all required fields + // The format is: 8 bytes header + NBT data + // Header: version (4 bytes LE) + length (4 bytes LE) + + // Use custom spawn point if provided, otherwise center of bbox + let (spawn_x, spawn_z) = self.spawn_point.unwrap_or_else(|| { + let x = (xzbbox.min_x() + xzbbox.max_x()) / 2; + let z = (xzbbox.min_z() + xzbbox.max_z()) / 2; + (x, z) + }); + + // Calculate spawn Y from ground elevation data, or default to 64 + let spawn_y = self + .ground + .as_ref() + .map(|ground| { + let coord = crate::coordinate_system::cartesian::XZPoint::new(spawn_x, spawn_z); + ground.level(coord) + 2 // Add 2 blocks above ground for safety + }) + .unwrap_or(64); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Version array for Bedrock 1.21.x compatibility + let version_array = vec![1, 21, 0, 0, 0]; + + // Build complete level.dat NBT structure + let level_dat = BedrockLevelDat { + // Version information - critical for Bedrock to recognize the world + storage_version: 10, + network_version: 685, // Bedrock 1.21.0 protocol + world_version: 1, + inventory_version: "1.21.0".to_string(), + last_opened_with_version: version_array.clone(), + minimum_compatible_client_version: version_array, + + // World identity + level_name: self.level_name.clone(), + random_seed: 0, + + // Spawn location (Y derived from terrain elevation) + spawn_x, + spawn_y, + spawn_z, + + // World generation - Flat/Void world + generator: 2, // Flat + flat_world_layers: r#"{"biome_id":1,"encoding_version":6,"preset_id":"TheVoid","world_version":"version.post_1_18"}"#.to_string(), + spawn_mobs: false, + + // Game settings + game_type: 1, // Creative + difficulty: 2, // Normal + force_game_type: false, + + // Time + last_played: now, + time: 0, + current_tick: 0, + + // Cheats and commands + commands_enabled: true, + cheats_enabled: true, + command_blocks_enabled: true, + command_block_output: true, + + // Multiplayer + multiplayer_game: true, + multiplayer_game_intent: true, + lan_broadcast: true, + lan_broadcast_intent: true, + xbl_broadcast_intent: 3, + platform_broadcast_intent: 3, + platform: 2, + + // Game rules + do_daylight_cycle: true, + do_weather_cycle: true, + do_mob_spawning: false, + do_mob_loot: true, + do_tile_drops: true, + do_entity_drops: true, + do_fire_tick: true, + mob_griefing: true, + natural_regeneration: true, + pvp: true, + keep_inventory: false, + send_command_feedback: true, + show_coordinates: false, + show_death_messages: true, + tnt_explodes: true, + respawn_blocks_explode: true, + projectiles_can_break_blocks: true, + + // Damage settings + drowning_damage: true, + fall_damage: true, + fire_damage: true, + freeze_damage: true, + + // Weather + rain_level: 0.0, + rain_time: 100000, + lightning_level: 0.0, + lightning_time: 100000, + + // Misc settings + nether_scale: 8, + spawn_radius: 0, + random_tick_speed: 1, + function_command_limit: 10000, + max_command_chain_length: 65535, + server_chunk_tick_range: 4, + limited_world_depth: 16, + limited_world_width: 16, + limited_world_origin_x: spawn_x, + limited_world_origin_y: 64, + limited_world_origin_z: spawn_z, + world_start_count: 0xFFFFFFFE_u64 as i64, // Special value for new worlds + + // Boolean flags + bonus_chest_enabled: false, + bonus_chest_spawned: false, + has_been_loaded_in_creative: true, + has_locked_behavior_pack: false, + has_locked_resource_pack: false, + immutable_world: false, + is_from_locked_template: false, + is_from_world_template: false, + is_single_use_world: false, + is_world_template_option_locked: false, + texture_packs_required: false, + use_msa_gamertags_only: false, + center_maps_to_origin: false, + confirmed_platform_locked_content: false, + education_features_enabled: false, + start_with_map_enabled: false, + requires_copied_pack_removal_check: false, + spawn_v1_villagers: false, + is_hardcore: false, + is_created_in_editor: false, + is_exported_from_editor: false, + is_random_seed_allowed: false, + has_uncomplete_world_file_on_disk: false, + player_has_died: false, + do_insomnia: true, + do_immediate_respawn: false, + do_limited_crafting: false, + recipes_unlock: true, + show_tags: true, + show_recipe_messages: true, + show_border_effect: true, + show_days_played: false, + locator_bar: true, + tnt_explosion_drop_decay: true, + saved_with_toggled_experiments: false, + experiments_ever_used: false, + + // Editor + editor_world_type: 0, + edu_offer: 0, + + // Override + biome_override: "".to_string(), + prid: "".to_string(), + + // Player sleeping + players_sleeping_percentage: 100, + + // Permissions + permissions_level: 0, + player_permissions_level: 1, + + // Daylight cycle + daylight_cycle: 0, + }; + + let nbt_bytes = + nbtx::to_le_bytes(&level_dat).map_err(|e| BedrockSaveError::Nbt(e.to_string()))?; + + // Write with header + let mut file = File::create(self.output_dir.join("level.dat"))?; + // Storage version: 10 (current Bedrock format) + file.write_u32::(10)?; + // Length of NBT data + file.write_u32::(nbt_bytes.len() as u32)?; + file.write_all(&nbt_bytes)?; + + Ok(()) + } + + fn write_chunks_to_db(&self, world: &WorldToModify) -> Result<(), BedrockSaveError> { + let db_path = self.output_dir.join("db"); + + // Open LevelDB with Bedrock-compatible options + let mut state = (); + let mut db: RustyDBInterface<()> = + RustyDBInterface::new(db_path.into_boxed_path(), true, &mut state) + .map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?; + + // Count total chunks for progress + let total_chunks: usize = world + .regions + .values() + .map(|region| region.chunks.len()) + .sum(); + + if total_chunks == 0 { + return Ok(()); + } + + let progress_bar = ProgressBar::new(total_chunks as u64); + progress_bar.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{bar:45.white/black}] {pos}/{len} chunks ({eta})") + .unwrap() + .progress_chars("█▓░"), + ); + + let mut chunks_processed: usize = 0; + + // Process each region and chunk + for ((region_x, region_z), region) in &world.regions { + for ((local_chunk_x, local_chunk_z), chunk) in ®ion.chunks { + // Calculate absolute chunk coordinates + let abs_chunk_x = region_x * 32 + local_chunk_x; + let abs_chunk_z = region_z * 32 + local_chunk_z; + let chunk_pos = Vec2::new(abs_chunk_x, abs_chunk_z); + + // Write chunk version marker (42 is current Bedrock version as of 1.21+) + let version_key = ChunkKey::chunk_marker(chunk_pos, Dimension::Overworld); + db.set_subchunk_raw(version_key, &[42], &mut state) + .map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?; + + // Write Data3D (heightmap + biomes) - required for chunk to be valid + let data3d_key = ChunkKey::data3d(chunk_pos, Dimension::Overworld); + let data3d = self.create_data3d(chunk); + db.set_subchunk_raw(data3d_key, &data3d, &mut state) + .map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?; + + // Process each section (subchunk) + for (§ion_y, section) in &chunk.sections { + // Encode the subchunk + let subchunk_bytes = self.encode_subchunk(section, section_y)?; + + // Write to database + let subchunk_key = + ChunkKey::new_subchunk(chunk_pos, Dimension::Overworld, section_y); + db.set_subchunk_raw(subchunk_key, &subchunk_bytes, &mut state) + .map_err(|e| BedrockSaveError::Database(format!("{:?}", e)))?; + } + + chunks_processed += 1; + progress_bar.inc(1); + + // Update GUI progress (92% to 97% range for chunk writing) + if chunks_processed.is_multiple_of(10) || chunks_processed == total_chunks { + let chunk_progress = chunks_processed as f64 / total_chunks as f64; + let gui_progress = 92.0 + (chunk_progress * 5.0); // 92% to 97% + emit_gui_progress_update(gui_progress, ""); + } + } + } + + progress_bar.finish_with_message("Chunks written to LevelDB"); + + // LevelDB writes are flushed when the database is dropped + drop(db); + + Ok(()) + } + + /// Creates a Data3D record containing heightmap and biome data. + /// + /// Format: 512 bytes heightmap (256 x i16 LE) + 28 bytes minimal biome data + fn create_data3d(&self, _chunk: &ChunkToModify) -> Vec { + let mut buffer = Vec::with_capacity(540); + + // Heightmap: 256 entries (16x16) as i16 LE, fixed height of 4 for flat world + for _ in 0..256 { + buffer.extend_from_slice(&4i16.to_le_bytes()); + } + + // Minimal biome data padding (biomes will be regenerated by the game) + buffer.extend_from_slice(&[0u8; 28]); + + buffer + } + + /// Encode a section into Bedrock subchunk format + fn encode_subchunk( + &self, + section: &SectionToModify, + y_index: i8, + ) -> Result, BedrockSaveError> { + let mut buffer = Cursor::new(Vec::new()); + + // Subchunk format version (9 is current) + buffer.write_u8(9)?; + + // Number of storage layers (we use 1) + buffer.write_u8(1)?; + + // Y index + buffer.write_i8(y_index)?; + + // Build palette and block indices + let (palette, indices) = self.build_palette_and_indices(section)?; + + // Calculate bits per block using valid Bedrock values: {1, 2, 3, 4, 5, 6, 8, 16} + let bits_per_block = bedrock_bits_per_block(palette.len() as u32); + + // Write palette type (bits << 1, not network format) + buffer.write_u8(bits_per_block << 1)?; + + // Calculate word packing parameters (matching Chunker's PaletteUtil exactly) + // blocksPerWord = floor(32 / bitsPerBlock) + // wordSize = ceil(4096 / blocksPerWord) + let blocks_per_word = 32 / bits_per_block as u32; // Integer division = floor + let word_count = 4096_u32.div_ceil(blocks_per_word); + let mask = (1u32 << bits_per_block) - 1; + + // Pack indices into 32-bit words (matching Chunker's loop exactly) + let mut block_index = 0usize; + for _ in 0..word_count { + let mut word = 0u32; + // Important: iterate blockIndex from 0 to blocksPerWord-1 + // NOT bit_offset from 0 to 32 in steps of bits_per_block + for block_in_word in 0..blocks_per_word { + if block_index >= 4096 { + break; + } + let start_bit_index = bits_per_block as u32 * block_in_word; + let index_val = indices[block_index] as u32 & mask; + word |= index_val << start_bit_index; + block_index += 1; + } + buffer.write_u32::(word)?; + } + + // Write palette count + buffer.write_u32::(palette.len() as u32)?; + + // Write palette entries as NBT + for block in &palette { + let state = BedrockBlockState { + name: block.name.clone(), + states: block + .states + .iter() + .map(|(k, v)| (k.clone(), BedrockNbtValue::from(v))) + .collect(), + }; + let nbt_bytes = + nbtx::to_le_bytes(&state).map_err(|e| BedrockSaveError::Nbt(e.to_string()))?; + buffer.write_all(&nbt_bytes)?; + } + + Ok(buffer.into_inner()) + } + + /// Builds a palette and index array from a section. + /// + /// Converts from internal YZX ordering to Bedrock's XZY ordering: + /// - Internal: index = y*256 + z*16 + x + /// - Bedrock: index = x*256 + z*16 + y + /// + /// Also propagates stored block properties (e.g., stair facing/shape) to the + /// Bedrock palette, ensuring blocks with non-default states are serialized correctly. + fn build_palette_and_indices( + &self, + section: &SectionToModify, + ) -> Result<(Vec, [u16; 4096]), BedrockSaveError> { + let mut palette: Vec = Vec::new(); + let mut palette_map: StdHashMap = StdHashMap::new(); + let mut indices = [0u16; 4096]; + + // Add air as first palette entry (required by Bedrock format) + let air_block = BedrockBlock::simple("air"); + let air_key = format!("{:?}", (&air_block.name, &air_block.states)); + palette.push(air_block); + palette_map.insert(air_key, 0); + + // Convert blocks from internal YZX to Bedrock XZY ordering + for x in 0..16usize { + for z in 0..16usize { + for y in 0..16usize { + let internal_idx = y * 256 + z * 16 + x; + let block = section.blocks[internal_idx]; + + // Get stored properties for this block position (if any) + let properties = section.properties.get(&internal_idx); + + // Convert to Bedrock format, preserving properties + let bedrock_block = to_bedrock_block_with_properties(block, properties); + let key = format!("{:?}", (&bedrock_block.name, &bedrock_block.states)); + + let palette_index = if let Some(&idx) = palette_map.get(&key) { + idx + } else { + let idx = palette.len() as u16; + palette_map.insert(key, idx); + palette.push(bedrock_block); + idx + }; + + let bedrock_idx = x * 256 + z * 16 + y; + indices[bedrock_idx] = palette_index; + } + } + } + + Ok((palette, indices)) + } + + fn write_metadata( + &self, + world: &WorldToModify, + xzbbox: &XZBBox, + llbbox: &LLBBox, + ) -> Result<(), BedrockSaveError> { + let chunk_count = world + .regions + .values() + .map(|region| region.chunks.len()) + .sum(); + + let metadata = BedrockMetadata { + world: WorldMetadata { + min_mc_x: xzbbox.min_x(), + max_mc_x: xzbbox.max_x(), + min_mc_z: xzbbox.min_z(), + max_mc_z: xzbbox.max_z(), + min_geo_lat: llbbox.min().lat(), + max_geo_lat: llbbox.max().lat(), + min_geo_lon: llbbox.min().lng(), + max_geo_lon: llbbox.max().lng(), + }, + format: "bedrock-mcworld", + chunk_count, + }; + + let metadata_bytes = serde_json::to_vec_pretty(&metadata)?; + let metadata_path = self.output_dir.join("metadata.json"); + let mut file = File::create(metadata_path)?; + file.write_all(&metadata_bytes)?; + Ok(()) + } + + fn package_mcworld(&self) -> Result<(), BedrockSaveError> { + let mcworld_path = self.output_dir.with_extension("mcworld"); + let file = File::create(&mcworld_path)?; + let mut writer = ZipWriter::new(file); + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + + // Add top-level files + for file_name in ["levelname.txt", "metadata.json", "level.dat"] { + let path = self.output_dir.join(file_name); + if path.exists() { + writer.start_file(file_name, options)?; + let contents = fs::read(&path)?; + writer.write_all(&contents)?; + } + } + + // Add world_icon.jpeg from embedded assets + const WORLD_ICON: &[u8] = include_bytes!("../../assets/minecraft/world_icon.jpeg"); + writer.start_file("world_icon.jpeg", options)?; + writer.write_all(WORLD_ICON)?; + + // Add db directory and its contents + let db_path = self.output_dir.join("db"); + if db_path.is_dir() { + add_directory_to_zip(&mut writer, &db_path, "db", options)?; + } + + writer.finish()?; + Ok(()) + } + + /// Clean up the temporary directory after packaging mcworld + fn cleanup_temp_dir(&self) -> Result<(), BedrockSaveError> { + if self.output_dir.exists() { + fs::remove_dir_all(&self.output_dir)?; + } + Ok(()) + } +} + +fn add_directory_to_zip( + writer: &mut ZipWriter, + dir_path: &std::path::Path, + zip_prefix: &str, + options: FileOptions, +) -> Result<(), BedrockSaveError> { + // Add directory entry + writer.add_directory(format!("{}/", zip_prefix), options)?; + + // Add all files in directory + for entry in fs::read_dir(dir_path)? { + let entry = entry?; + let path = entry.path(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + let zip_path = format!("{}/{}", zip_prefix, name); + + if path.is_file() { + writer.start_file(&zip_path, options)?; + let contents = fs::read(&path)?; + writer.write_all(&contents)?; + } else if path.is_dir() { + add_directory_to_zip(writer, &path, &zip_path, options)?; + } + } + + Ok(()) +} + +/// Calculate bits per block using valid Bedrock values: {1, 2, 3, 4, 5, 6, 8, 16} +#[inline] +fn bedrock_bits_per_block(palette_count: u32) -> u8 { + const VALID_BITS: [u8; 8] = [1, 2, 3, 4, 5, 6, 8, 16]; + for &bits in &VALID_BITS { + if palette_count <= (1u32 << bits) { + return bits; + } + } + 16 // Maximum +} + +/// Level.dat structure for Bedrock Edition +/// This struct contains all required fields for a valid Bedrock world +#[derive(serde::Serialize)] +struct BedrockLevelDat { + // Version information + #[serde(rename = "StorageVersion")] + storage_version: i32, + #[serde(rename = "NetworkVersion")] + network_version: i32, + #[serde(rename = "WorldVersion")] + world_version: i32, + #[serde(rename = "InventoryVersion")] + inventory_version: String, + #[serde(rename = "lastOpenedWithVersion")] + last_opened_with_version: Vec, + #[serde(rename = "MinimumCompatibleClientVersion")] + minimum_compatible_client_version: Vec, + + // World identity + #[serde(rename = "LevelName")] + level_name: String, + #[serde(rename = "RandomSeed")] + random_seed: i64, + + // Spawn location + #[serde(rename = "SpawnX")] + spawn_x: i32, + #[serde(rename = "SpawnY")] + spawn_y: i32, + #[serde(rename = "SpawnZ")] + spawn_z: i32, + + // World generation + #[serde(rename = "Generator")] + generator: i32, + #[serde(rename = "FlatWorldLayers")] + flat_world_layers: String, + #[serde(rename = "spawnMobs")] + spawn_mobs: bool, + + // Game settings + #[serde(rename = "GameType")] + game_type: i32, + #[serde(rename = "Difficulty")] + difficulty: i32, + #[serde(rename = "ForceGameType")] + force_game_type: bool, + + // Time + #[serde(rename = "LastPlayed")] + last_played: i64, + #[serde(rename = "Time")] + time: i64, + #[serde(rename = "currentTick")] + current_tick: i64, + + // Cheats and commands + #[serde(rename = "commandsEnabled")] + commands_enabled: bool, + #[serde(rename = "cheatsEnabled")] + cheats_enabled: bool, + #[serde(rename = "commandblocksenabled")] + command_blocks_enabled: bool, + #[serde(rename = "commandblockoutput")] + command_block_output: bool, + + // Multiplayer + #[serde(rename = "MultiplayerGame")] + multiplayer_game: bool, + #[serde(rename = "MultiplayerGameIntent")] + multiplayer_game_intent: bool, + #[serde(rename = "LANBroadcast")] + lan_broadcast: bool, + #[serde(rename = "LANBroadcastIntent")] + lan_broadcast_intent: bool, + #[serde(rename = "XBLBroadcastIntent")] + xbl_broadcast_intent: i32, + #[serde(rename = "PlatformBroadcastIntent")] + platform_broadcast_intent: i32, + #[serde(rename = "Platform")] + platform: i32, + + // Game rules + #[serde(rename = "dodaylightcycle")] + do_daylight_cycle: bool, + #[serde(rename = "doweathercycle")] + do_weather_cycle: bool, + #[serde(rename = "domobspawning")] + do_mob_spawning: bool, + #[serde(rename = "domobloot")] + do_mob_loot: bool, + #[serde(rename = "dotiledrops")] + do_tile_drops: bool, + #[serde(rename = "doentitydrops")] + do_entity_drops: bool, + #[serde(rename = "dofiretick")] + do_fire_tick: bool, + #[serde(rename = "mobgriefing")] + mob_griefing: bool, + #[serde(rename = "naturalregeneration")] + natural_regeneration: bool, + #[serde(rename = "pvp")] + pvp: bool, + #[serde(rename = "keepinventory")] + keep_inventory: bool, + #[serde(rename = "sendcommandfeedback")] + send_command_feedback: bool, + #[serde(rename = "showcoordinates")] + show_coordinates: bool, + #[serde(rename = "showdeathmessages")] + show_death_messages: bool, + #[serde(rename = "tntexplodes")] + tnt_explodes: bool, + #[serde(rename = "respawnblocksexplode")] + respawn_blocks_explode: bool, + #[serde(rename = "projectilescanbreakblocks")] + projectiles_can_break_blocks: bool, + + // Damage settings + #[serde(rename = "drowningdamage")] + drowning_damage: bool, + #[serde(rename = "falldamage")] + fall_damage: bool, + #[serde(rename = "firedamage")] + fire_damage: bool, + #[serde(rename = "freezedamage")] + freeze_damage: bool, + + // Weather + #[serde(rename = "rainLevel")] + rain_level: f32, + #[serde(rename = "rainTime")] + rain_time: i32, + #[serde(rename = "lightningLevel")] + lightning_level: f32, + #[serde(rename = "lightningTime")] + lightning_time: i32, + + // Misc settings + #[serde(rename = "NetherScale")] + nether_scale: i32, + #[serde(rename = "spawnradius")] + spawn_radius: i32, + #[serde(rename = "randomtickspeed")] + random_tick_speed: i32, + #[serde(rename = "functioncommandlimit")] + function_command_limit: i32, + #[serde(rename = "maxcommandchainlength")] + max_command_chain_length: i32, + #[serde(rename = "serverChunkTickRange")] + server_chunk_tick_range: i32, + #[serde(rename = "limitedWorldDepth")] + limited_world_depth: i32, + #[serde(rename = "limitedWorldWidth")] + limited_world_width: i32, + #[serde(rename = "LimitedWorldOriginX")] + limited_world_origin_x: i32, + #[serde(rename = "LimitedWorldOriginY")] + limited_world_origin_y: i32, + #[serde(rename = "LimitedWorldOriginZ")] + limited_world_origin_z: i32, + #[serde(rename = "worldStartCount")] + world_start_count: i64, + + // Boolean flags + #[serde(rename = "bonusChestEnabled")] + bonus_chest_enabled: bool, + #[serde(rename = "bonusChestSpawned")] + bonus_chest_spawned: bool, + #[serde(rename = "hasBeenLoadedInCreative")] + has_been_loaded_in_creative: bool, + #[serde(rename = "hasLockedBehaviorPack")] + has_locked_behavior_pack: bool, + #[serde(rename = "hasLockedResourcePack")] + has_locked_resource_pack: bool, + #[serde(rename = "immutableWorld")] + immutable_world: bool, + #[serde(rename = "isFromLockedTemplate")] + is_from_locked_template: bool, + #[serde(rename = "isFromWorldTemplate")] + is_from_world_template: bool, + #[serde(rename = "isSingleUseWorld")] + is_single_use_world: bool, + #[serde(rename = "isWorldTemplateOptionLocked")] + is_world_template_option_locked: bool, + #[serde(rename = "texturePacksRequired")] + texture_packs_required: bool, + #[serde(rename = "useMsaGamertagsOnly")] + use_msa_gamertags_only: bool, + #[serde(rename = "CenterMapsToOrigin")] + center_maps_to_origin: bool, + #[serde(rename = "ConfirmedPlatformLockedContent")] + confirmed_platform_locked_content: bool, + #[serde(rename = "educationFeaturesEnabled")] + education_features_enabled: bool, + #[serde(rename = "startWithMapEnabled")] + start_with_map_enabled: bool, + #[serde(rename = "requiresCopiedPackRemovalCheck")] + requires_copied_pack_removal_check: bool, + #[serde(rename = "SpawnV1Villagers")] + spawn_v1_villagers: bool, + #[serde(rename = "IsHardcore")] + is_hardcore: bool, + #[serde(rename = "isCreatedInEditor")] + is_created_in_editor: bool, + #[serde(rename = "isExportedFromEditor")] + is_exported_from_editor: bool, + #[serde(rename = "isRandomSeedAllowed")] + is_random_seed_allowed: bool, + #[serde(rename = "HasUncompleteWorldFileOnDisk")] + has_uncomplete_world_file_on_disk: bool, + #[serde(rename = "PlayerHasDied")] + player_has_died: bool, + #[serde(rename = "doinsomnia")] + do_insomnia: bool, + #[serde(rename = "doimmediaterespawn")] + do_immediate_respawn: bool, + #[serde(rename = "dolimitedcrafting")] + do_limited_crafting: bool, + #[serde(rename = "recipesunlock")] + recipes_unlock: bool, + #[serde(rename = "showtags")] + show_tags: bool, + #[serde(rename = "showrecipemessages")] + show_recipe_messages: bool, + #[serde(rename = "showbordereffect")] + show_border_effect: bool, + #[serde(rename = "showdaysplayed")] + show_days_played: bool, + #[serde(rename = "locatorbar")] + locator_bar: bool, + #[serde(rename = "tntexplosiondropdecay")] + tnt_explosion_drop_decay: bool, + #[serde(rename = "saved_with_toggled_experiments")] + saved_with_toggled_experiments: bool, + #[serde(rename = "experiments_ever_used")] + experiments_ever_used: bool, + + // Editor + #[serde(rename = "editorWorldType")] + editor_world_type: i32, + #[serde(rename = "eduOffer")] + edu_offer: i32, + + // Override + #[serde(rename = "BiomeOverride")] + biome_override: String, + #[serde(rename = "prid")] + prid: String, + + // Player sleeping + #[serde(rename = "playerssleepingpercentage")] + players_sleeping_percentage: i32, + + // Permissions + #[serde(rename = "permissionsLevel")] + permissions_level: i32, + #[serde(rename = "playerPermissionsLevel")] + player_permissions_level: i32, + + // Daylight cycle + #[serde(rename = "daylightCycle")] + daylight_cycle: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use zip::ZipArchive; + + #[test] + fn writes_mcworld_package_with_metadata() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let output_dir = temp_dir.path().join("bedrock_world"); + + let world = WorldToModify::default(); + let xzbbox = XZBBox::rect_from_xz_lengths(15.0, 15.0).unwrap(); + let llbbox = LLBBox::new(0.0, 0.0, 1.0, 1.0).unwrap(); + + BedrockWriter::new(output_dir.clone(), "test-world".to_string(), None, None) + .write_world(&world, &xzbbox, &llbbox) + .expect("write_world"); + + // The temp directory should be cleaned up, but mcworld should exist + let mcworld_path = output_dir.with_extension("mcworld"); + let file = fs::File::open(&mcworld_path).expect("mcworld archive exists"); + let mut archive = ZipArchive::new(file).expect("zip readable"); + + let mut entries: Vec = Vec::new(); + for i in 0..archive.len() { + if let Ok(file) = archive.by_index(i) { + entries.push(file.name().to_string()); + } + } + entries.sort(); + + assert!(entries.contains(&"db/".to_string())); + assert!(entries.contains(&"levelname.txt".to_string())); + assert!(entries.contains(&"metadata.json".to_string())); + + // Check metadata inside the archive + let metadata_file = archive + .by_name("metadata.json") + .expect("metadata in archive"); + let metadata: Value = serde_json::from_reader(metadata_file).expect("valid metadata JSON"); + + assert_eq!(metadata["format"], "bedrock-mcworld"); + assert_eq!(metadata["chunk_count"], 0); // empty world structure + } + + #[test] + fn writes_mcworld_with_custom_spawn_point() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let output_dir = temp_dir.path().join("bedrock_world_spawn"); + + let world = WorldToModify::default(); + let xzbbox = XZBBox::rect_from_xz_lengths(100.0, 100.0).unwrap(); + let llbbox = LLBBox::new(0.0, 0.0, 1.0, 1.0).unwrap(); + + // Custom spawn point at (42, 84) + BedrockWriter::new( + output_dir.clone(), + "spawn-test".to_string(), + Some((42, 84)), + None, + ) + .write_world(&world, &xzbbox, &llbbox) + .expect("write_world"); + + // Verify the mcworld was created + let mcworld_path = output_dir.with_extension("mcworld"); + assert!(mcworld_path.exists(), "mcworld file should exist"); + } +} diff --git a/src/world_editor/common.rs b/src/world_editor/common.rs new file mode 100644 index 0000000..43a38a0 --- /dev/null +++ b/src/world_editor/common.rs @@ -0,0 +1,312 @@ +//! Common data structures for world modification. +//! +//! This module contains the internal data structures used to track block changes +//! before they are written to either Java or Bedrock format. + +use crate::block_definitions::*; +use fastnbt::{LongArray, Value}; +use fnv::FnvHashMap; +use serde::{Deserialize, Serialize}; + +/// Chunk structure for Java Edition NBT format +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Chunk { + pub sections: Vec, + pub x_pos: i32, + pub z_pos: i32, + #[serde(default)] + pub is_light_on: u8, + #[serde(flatten)] + pub other: FnvHashMap, +} + +/// Section within a chunk (16x16x16 blocks) +#[derive(Serialize, Deserialize)] +pub(crate) struct Section { + pub block_states: Blockstates, + #[serde(rename = "Y")] + pub y: i8, + #[serde(flatten)] + pub other: FnvHashMap, +} + +/// Block states within a section +#[derive(Serialize, Deserialize)] +pub(crate) struct Blockstates { + pub palette: Vec, + pub data: Option, + #[serde(flatten)] + pub other: FnvHashMap, +} + +/// Palette item for block state encoding +#[derive(Serialize, Deserialize)] +pub(crate) struct PaletteItem { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Properties")] + pub properties: Option, +} + +/// A section being modified (16x16x16 blocks) +pub(crate) struct SectionToModify { + pub blocks: [Block; 4096], + /// Store properties for blocks that have them, indexed by the same index as blocks array + pub properties: FnvHashMap, +} + +impl SectionToModify { + #[inline] + pub fn get_block(&self, x: u8, y: u8, z: u8) -> Option { + let b = self.blocks[Self::index(x, y, z)]; + if b == AIR { + return None; + } + Some(b) + } + + #[inline] + pub fn set_block(&mut self, x: u8, y: u8, z: u8, block: Block) { + self.blocks[Self::index(x, y, z)] = block; + } + + #[inline] + pub fn set_block_with_properties( + &mut self, + x: u8, + y: u8, + z: u8, + block_with_props: BlockWithProperties, + ) { + let index = Self::index(x, y, z); + self.blocks[index] = block_with_props.block; + + // Store properties if they exist + if let Some(props) = block_with_props.properties { + self.properties.insert(index, props); + } else { + // Remove any existing properties for this position + self.properties.remove(&index); + } + } + + /// Calculate index from coordinates (YZX order) + #[inline(always)] + pub fn index(x: u8, y: u8, z: u8) -> usize { + usize::from(y) % 16 * 256 + usize::from(z) * 16 + usize::from(x) + } + + /// Convert to Java Edition section format + pub fn to_section(&self, y: i8) -> Section { + // Create a map of unique block+properties combinations to palette indices + let mut unique_blocks: Vec<(Block, Option)> = Vec::new(); + let mut palette_lookup: FnvHashMap<(Block, Option), usize> = FnvHashMap::default(); + + // Build unique block combinations and lookup table + for (i, &block) in self.blocks.iter().enumerate() { + let properties = self.properties.get(&i).cloned(); + + // Create a key for the lookup (block + properties hash) + let props_key = properties.as_ref().map(|p| format!("{p:?}")); + let lookup_key = (block, props_key); + + if let std::collections::hash_map::Entry::Vacant(e) = palette_lookup.entry(lookup_key) { + let palette_index = unique_blocks.len(); + e.insert(palette_index); + unique_blocks.push((block, properties)); + } + } + + let mut bits_per_block = 4; // minimum allowed + while (1 << bits_per_block) < unique_blocks.len() { + bits_per_block += 1; + } + + let mut data = vec![]; + let mut cur = 0; + let mut cur_idx = 0; + + for (i, &block) in self.blocks.iter().enumerate() { + let properties = self.properties.get(&i).cloned(); + let props_key = properties.as_ref().map(|p| format!("{p:?}")); + let lookup_key = (block, props_key); + let p = palette_lookup[&lookup_key] as i64; + + if cur_idx + bits_per_block > 64 { + data.push(cur); + cur = 0; + cur_idx = 0; + } + + cur |= p << cur_idx; + cur_idx += bits_per_block; + } + + if cur_idx > 0 { + data.push(cur); + } + + let palette = unique_blocks + .iter() + .map(|(block, stored_props)| PaletteItem { + name: block.name().to_string(), + properties: stored_props.clone().or_else(|| block.properties()), + }) + .collect(); + + Section { + block_states: Blockstates { + palette, + data: Some(LongArray::new(data)), + other: FnvHashMap::default(), + }, + y, + other: FnvHashMap::default(), + } + } +} + +impl Default for SectionToModify { + fn default() -> Self { + Self { + blocks: [AIR; 4096], + properties: FnvHashMap::default(), + } + } +} + +/// A chunk being modified (16x384x16 blocks, divided into sections) +#[derive(Default)] +pub(crate) struct ChunkToModify { + pub sections: FnvHashMap, + pub other: FnvHashMap, +} + +impl ChunkToModify { + #[inline] + pub fn get_block(&self, x: u8, y: i32, z: u8) -> Option { + let section_idx: i8 = (y >> 4).try_into().unwrap(); + let section = self.sections.get(§ion_idx)?; + section.get_block(x, (y & 15).try_into().unwrap(), z) + } + + #[inline] + pub fn set_block(&mut self, x: u8, y: i32, z: u8, block: Block) { + let section_idx: i8 = (y >> 4).try_into().unwrap(); + let section = self.sections.entry(section_idx).or_default(); + section.set_block(x, (y & 15).try_into().unwrap(), z, block); + } + + #[inline] + pub fn set_block_with_properties( + &mut self, + x: u8, + y: i32, + z: u8, + block_with_props: BlockWithProperties, + ) { + let section_idx: i8 = (y >> 4).try_into().unwrap(); + let section = self.sections.entry(section_idx).or_default(); + section.set_block_with_properties(x, (y & 15).try_into().unwrap(), z, block_with_props); + } + + pub fn sections(&self) -> impl Iterator + '_ { + self.sections.iter().map(|(y, s)| s.to_section(*y)) + } +} + +/// A region being modified (32x32 chunks) +#[derive(Default)] +pub(crate) struct RegionToModify { + pub chunks: FnvHashMap<(i32, i32), ChunkToModify>, +} + +impl RegionToModify { + #[inline] + pub fn get_or_create_chunk(&mut self, x: i32, z: i32) -> &mut ChunkToModify { + self.chunks.entry((x, z)).or_default() + } + + #[inline] + pub fn get_chunk(&self, x: i32, z: i32) -> Option<&ChunkToModify> { + self.chunks.get(&(x, z)) + } +} + +/// The entire world being modified +#[derive(Default)] +pub(crate) struct WorldToModify { + pub regions: FnvHashMap<(i32, i32), RegionToModify>, +} + +impl WorldToModify { + #[inline] + pub fn get_or_create_region(&mut self, x: i32, z: i32) -> &mut RegionToModify { + self.regions.entry((x, z)).or_default() + } + + #[inline] + pub fn get_region(&self, x: i32, z: i32) -> Option<&RegionToModify> { + self.regions.get(&(x, z)) + } + + #[inline] + pub fn get_block(&self, x: i32, y: i32, z: i32) -> Option { + let chunk_x: i32 = x >> 4; + let chunk_z: i32 = z >> 4; + let region_x: i32 = chunk_x >> 5; + let region_z: i32 = chunk_z >> 5; + + let region: &RegionToModify = self.get_region(region_x, region_z)?; + let chunk: &ChunkToModify = region.get_chunk(chunk_x & 31, chunk_z & 31)?; + + chunk.get_block( + (x & 15).try_into().unwrap(), + y, + (z & 15).try_into().unwrap(), + ) + } + + #[inline] + pub fn set_block(&mut self, x: i32, y: i32, z: i32, block: Block) { + let chunk_x: i32 = x >> 4; + let chunk_z: i32 = z >> 4; + let region_x: i32 = chunk_x >> 5; + let region_z: i32 = chunk_z >> 5; + + let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z); + let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31); + + chunk.set_block( + (x & 15).try_into().unwrap(), + y, + (z & 15).try_into().unwrap(), + block, + ); + } + + #[inline] + pub fn set_block_with_properties( + &mut self, + x: i32, + y: i32, + z: i32, + block_with_props: BlockWithProperties, + ) { + let chunk_x: i32 = x >> 4; + let chunk_z: i32 = z >> 4; + let region_x: i32 = chunk_x >> 5; + let region_z: i32 = chunk_z >> 5; + + let region: &mut RegionToModify = self.get_or_create_region(region_x, region_z); + let chunk: &mut ChunkToModify = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31); + + chunk.set_block_with_properties( + (x & 15).try_into().unwrap(), + y, + (z & 15).try_into().unwrap(), + block_with_props, + ); + } +} diff --git a/src/world_editor/java.rs b/src/world_editor/java.rs new file mode 100644 index 0000000..d85eab0 --- /dev/null +++ b/src/world_editor/java.rs @@ -0,0 +1,321 @@ +//! Java Edition Anvil format world saving. +//! +//! This module handles saving worlds in the Java Edition Anvil (.mca) format. + +use super::common::{Chunk, ChunkToModify, Section}; +use super::WorldEditor; +use crate::block_definitions::GRASS_BLOCK; +use crate::progress::emit_gui_progress_update; +use colored::Colorize; +use fastanvil::Region; +use fastnbt::Value; +use fnv::FnvHashMap; +use indicatif::{ProgressBar, ProgressStyle}; +use rayon::prelude::*; +use std::collections::HashMap; +use std::fs::File; +use std::io::Write; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[cfg(feature = "gui")] +use crate::telemetry::{send_log, LogLevel}; + +impl<'a> WorldEditor<'a> { + /// Creates a region file for the given region coordinates. + pub(super) fn create_region(&self, region_x: i32, region_z: i32) -> Region { + let out_path = self + .world_dir + .join(format!("region/r.{}.{}.mca", region_x, region_z)); + + const REGION_TEMPLATE: &[u8] = include_bytes!("../../assets/minecraft/region.template"); + + let mut region_file: File = File::options() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&out_path) + .expect("Failed to open region file"); + + region_file + .write_all(REGION_TEMPLATE) + .expect("Could not write region template"); + + Region::from_stream(region_file).expect("Failed to load region") + } + + /// Helper function to create a base chunk with grass blocks at Y -62 + pub(super) fn create_base_chunk(abs_chunk_x: i32, abs_chunk_z: i32) -> (Vec, bool) { + let mut chunk = ChunkToModify::default(); + + // Fill the bottom layer with grass blocks at Y -62 + for x in 0..16 { + for z in 0..16 { + chunk.set_block(x, -62, z, GRASS_BLOCK); + } + } + + // Prepare chunk data + let chunk_data = Chunk { + sections: chunk.sections().collect(), + x_pos: abs_chunk_x, + z_pos: abs_chunk_z, + is_light_on: 0, + other: chunk.other, + }; + + // Create the Level wrapper + let level_data = create_level_wrapper(&chunk_data); + + // Serialize the chunk with Level wrapper + let mut ser_buffer = Vec::with_capacity(8192); + fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap(); + + (ser_buffer, true) + } + + /// Saves the world in Java Edition Anvil format. + pub(super) fn save_java(&mut self) { + println!("{} Saving world...", "[7/7]".bold()); + emit_gui_progress_update(90.0, "Saving world..."); + + // Save metadata with error handling + if let Err(e) = self.save_metadata() { + eprintln!("Failed to save world metadata: {}", e); + #[cfg(feature = "gui")] + send_log(LogLevel::Warning, "Failed to save world metadata."); + // Continue with world saving even if metadata fails + } + + let total_regions = self.world.regions.len() as u64; + let save_pb = ProgressBar::new(total_regions); + save_pb.set_style( + ProgressStyle::default_bar() + .template( + "{spinner:.green} [{elapsed_precise}] [{bar:45}] {pos}/{len} regions ({eta})", + ) + .unwrap() + .progress_chars("█▓░"), + ); + + let regions_processed = AtomicU64::new(0); + + self.world + .regions + .par_iter() + .for_each(|((region_x, region_z), region_to_modify)| { + let mut region = self.create_region(*region_x, *region_z); + let mut ser_buffer = Vec::with_capacity(8192); + + for (&(chunk_x, chunk_z), chunk_to_modify) in ®ion_to_modify.chunks { + if !chunk_to_modify.sections.is_empty() || !chunk_to_modify.other.is_empty() { + // Read existing chunk data if it exists + let existing_data = region + .read_chunk(chunk_x as usize, chunk_z as usize) + .unwrap() + .unwrap_or_default(); + + // Parse existing chunk or create new one + let mut chunk: Chunk = if !existing_data.is_empty() { + fastnbt::from_bytes(&existing_data).unwrap() + } else { + Chunk { + sections: Vec::new(), + x_pos: chunk_x + (region_x * 32), + z_pos: chunk_z + (region_z * 32), + is_light_on: 0, + other: FnvHashMap::default(), + } + }; + + // Update sections while preserving existing data + let new_sections: Vec = chunk_to_modify.sections().collect(); + for new_section in new_sections { + if let Some(existing_section) = + chunk.sections.iter_mut().find(|s| s.y == new_section.y) + { + // Merge block states + existing_section.block_states.palette = + new_section.block_states.palette; + existing_section.block_states.data = new_section.block_states.data; + } else { + // Add new section if it doesn't exist + chunk.sections.push(new_section); + } + } + + // Preserve existing block entities and merge with new ones + if let Some(existing_entities) = chunk.other.get_mut("block_entities") { + if let Some(new_entities) = chunk_to_modify.other.get("block_entities") + { + if let (Value::List(existing), Value::List(new)) = + (existing_entities, new_entities) + { + // Remove old entities that are replaced by new ones + existing.retain(|e| { + if let Value::Compound(map) = e { + let (x, y, z) = get_entity_coords(map); + !new.iter().any(|new_e| { + if let Value::Compound(new_map) = new_e { + let (nx, ny, nz) = get_entity_coords(new_map); + x == nx && y == ny && z == nz + } else { + false + } + }) + } else { + true + } + }); + // Add new entities + existing.extend(new.clone()); + } + } + } else { + // If no existing entities, just add the new ones + if let Some(new_entities) = chunk_to_modify.other.get("block_entities") + { + chunk + .other + .insert("block_entities".to_string(), new_entities.clone()); + } + } + + // Update chunk coordinates and flags + chunk.x_pos = chunk_x + (region_x * 32); + chunk.z_pos = chunk_z + (region_z * 32); + + // Create Level wrapper and save + let level_data = create_level_wrapper(&chunk); + ser_buffer.clear(); + fastnbt::to_writer(&mut ser_buffer, &level_data).unwrap(); + region + .write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer) + .unwrap(); + } + } + + // Second pass: ensure all chunks exist + for chunk_x in 0..32 { + for chunk_z in 0..32 { + let abs_chunk_x = chunk_x + (region_x * 32); + let abs_chunk_z = chunk_z + (region_z * 32); + + // Check if chunk exists in our modifications + let chunk_exists = + region_to_modify.chunks.contains_key(&(chunk_x, chunk_z)); + + // If chunk doesn't exist, create it with base layer + if !chunk_exists { + let (ser_buffer, _) = Self::create_base_chunk(abs_chunk_x, abs_chunk_z); + region + .write_chunk(chunk_x as usize, chunk_z as usize, &ser_buffer) + .unwrap(); + } + } + } + + // Update progress + let regions_done = regions_processed.fetch_add(1, Ordering::SeqCst) + 1; + + // Update progress at regular intervals (every ~1% or at least every 10 regions) + // This ensures progress is visible even with many regions + let update_interval = (total_regions / 10).max(1); + if regions_done.is_multiple_of(update_interval) || regions_done == total_regions { + let progress = 90.0 + (regions_done as f64 / total_regions as f64) * 9.0; + emit_gui_progress_update(progress, "Saving world..."); + } + + save_pb.inc(1); + }); + + save_pb.finish(); + } +} + +/// Helper function to get entity coordinates +#[inline] +fn get_entity_coords(entity: &HashMap) -> (i32, i32, i32) { + let x = if let Value::Int(x) = entity.get("x").unwrap_or(&Value::Int(0)) { + *x + } else { + 0 + }; + let y = if let Value::Int(y) = entity.get("y").unwrap_or(&Value::Int(0)) { + *y + } else { + 0 + }; + let z = if let Value::Int(z) = entity.get("z").unwrap_or(&Value::Int(0)) { + *z + } else { + 0 + }; + (x, y, z) +} + +/// Creates a Level wrapper for chunk data (Java Edition format) +#[inline] +fn create_level_wrapper(chunk: &Chunk) -> HashMap { + HashMap::from([( + "Level".to_string(), + Value::Compound(HashMap::from([ + ("xPos".to_string(), Value::Int(chunk.x_pos)), + ("zPos".to_string(), Value::Int(chunk.z_pos)), + ( + "isLightOn".to_string(), + Value::Byte(i8::try_from(chunk.is_light_on).unwrap()), + ), + ( + "sections".to_string(), + Value::List( + chunk + .sections + .iter() + .map(|section| { + let mut block_states = HashMap::from([( + "palette".to_string(), + Value::List( + section + .block_states + .palette + .iter() + .map(|item| { + let mut palette_item = HashMap::from([( + "Name".to_string(), + Value::String(item.name.clone()), + )]); + if let Some(props) = &item.properties { + palette_item.insert( + "Properties".to_string(), + props.clone(), + ); + } + Value::Compound(palette_item) + }) + .collect(), + ), + )]); + + // Only add the `data` attribute if it's non-empty + // to maintain compatibility with third-party tools like Dynmap + if let Some(data) = §ion.block_states.data { + if !data.is_empty() { + block_states.insert( + "data".to_string(), + Value::LongArray(data.to_owned()), + ); + } + } + + Value::Compound(HashMap::from([ + ("Y".to_string(), Value::Byte(section.y)), + ("block_states".to_string(), Value::Compound(block_states)), + ])) + }) + .collect(), + ), + ), + ])), + )]) +} diff --git a/src/world_editor/mod.rs b/src/world_editor/mod.rs new file mode 100644 index 0000000..0ecda8c --- /dev/null +++ b/src/world_editor/mod.rs @@ -0,0 +1,587 @@ +//! World editor module for generating Minecraft worlds. +//! +//! This module provides the `WorldEditor` struct which handles block placement +//! and world saving in both Java Edition (Anvil) and Bedrock Edition (.mcworld) formats. +//! +//! # Module Structure +//! +//! - `common` - Shared data structures for world modification +//! - `java` - Java Edition Anvil format saving +//! - `bedrock` - Bedrock Edition .mcworld format saving (behind `bedrock` feature) + +mod common; +mod java; + +#[cfg(feature = "bedrock")] +pub mod bedrock; + +// Re-export common types used internally +pub(crate) use common::WorldToModify; + +#[cfg(feature = "bedrock")] +pub(crate) use bedrock::{BedrockSaveError, BedrockWriter}; + +use crate::block_definitions::*; +use crate::coordinate_system::cartesian::{XZBBox, XZPoint}; +use crate::coordinate_system::geographic::LLBBox; +use crate::ground::Ground; +use crate::progress::emit_gui_progress_update; +use colored::Colorize; +use fastnbt::Value; +use serde::Serialize; +use std::collections::HashMap; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +#[cfg(feature = "gui")] +use crate::telemetry::{send_log, LogLevel}; + +/// World format to generate +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] +pub enum WorldFormat { + /// Java Edition Anvil format (.mca region files) + JavaAnvil, + /// Bedrock Edition .mcworld format + BedrockMcWorld, +} + +/// Metadata saved with the world +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorldMetadata { + pub min_mc_x: i32, + pub max_mc_x: i32, + pub min_mc_z: i32, + pub max_mc_z: i32, + + pub min_geo_lat: f64, + pub max_geo_lat: f64, + pub min_geo_lon: f64, + pub max_geo_lon: f64, +} + +/// The main world editor struct for placing blocks and saving worlds. +/// +/// The lifetime `'a` is tied to the `XZBBox` reference, which defines +/// the world boundaries and must outlive the WorldEditor instance. +pub struct WorldEditor<'a> { + world_dir: PathBuf, + world: WorldToModify, + xzbbox: &'a XZBBox, + llbbox: LLBBox, + ground: Option>, + format: WorldFormat, + /// Optional level name for Bedrock worlds (e.g., "Arnis World: New York City") + bedrock_level_name: Option, + /// Optional spawn point for Bedrock worlds (x, z coordinates) + bedrock_spawn_point: Option<(i32, i32)>, +} + +impl<'a> WorldEditor<'a> { + /// Creates a new WorldEditor with Java Anvil format (default). + /// + /// This is the default constructor used by CLI mode. + #[allow(dead_code)] + pub fn new(world_dir: PathBuf, xzbbox: &'a XZBBox, llbbox: LLBBox) -> Self { + Self { + world_dir, + world: WorldToModify::default(), + xzbbox, + llbbox, + ground: None, + format: WorldFormat::JavaAnvil, + bedrock_level_name: None, + bedrock_spawn_point: None, + } + } + + /// Creates a new WorldEditor with a specific format and optional level name. + /// + /// Used by GUI mode to support both Java and Bedrock formats. + #[allow(dead_code)] + pub fn new_with_format_and_name( + world_dir: PathBuf, + xzbbox: &'a XZBBox, + llbbox: LLBBox, + format: WorldFormat, + bedrock_level_name: Option, + bedrock_spawn_point: Option<(i32, i32)>, + ) -> Self { + Self { + world_dir, + world: WorldToModify::default(), + xzbbox, + llbbox, + ground: None, + format, + bedrock_level_name, + bedrock_spawn_point, + } + } + + /// Sets the ground reference for elevation-based block placement + pub fn set_ground(&mut self, ground: &Ground) { + self.ground = Some(Box::new(ground.clone())); + } + + /// Gets a reference to the ground data if available + pub fn get_ground(&self) -> Option<&Ground> { + self.ground.as_ref().map(|g| g.as_ref()) + } + + /// Returns the current world format + #[allow(dead_code)] + pub fn format(&self) -> WorldFormat { + self.format + } + + /// Calculate the absolute Y position from a ground-relative offset + #[inline(always)] + pub fn get_absolute_y(&self, x: i32, y_offset: i32, z: i32) -> i32 { + if let Some(ground) = &self.ground { + ground.level(XZPoint::new( + x - self.xzbbox.min_x(), + z - self.xzbbox.min_z(), + )) + y_offset + } else { + y_offset // If no ground reference, use y_offset as absolute Y + } + } + + /// Returns the minimum world coordinates + pub fn get_min_coords(&self) -> (i32, i32) { + (self.xzbbox.min_x(), self.xzbbox.min_z()) + } + + /// Returns the maximum world coordinates + pub fn get_max_coords(&self) -> (i32, i32) { + (self.xzbbox.max_x(), self.xzbbox.max_z()) + } + + /// Checks if there's a block at the given coordinates + #[allow(unused)] + #[inline] + pub fn block_at(&self, x: i32, y: i32, z: i32) -> bool { + let absolute_y = self.get_absolute_y(x, y, z); + self.world.get_block(x, absolute_y, z).is_some() + } + + /// Sets a sign at the given coordinates + #[allow(clippy::too_many_arguments, dead_code)] + pub fn set_sign( + &mut self, + line1: String, + line2: String, + line3: String, + line4: String, + x: i32, + y: i32, + z: i32, + _rotation: i8, + ) { + let absolute_y = self.get_absolute_y(x, y, z); + let chunk_x = x >> 4; + let chunk_z = z >> 4; + let region_x = chunk_x >> 5; + let region_z = chunk_z >> 5; + + let mut block_entities = HashMap::new(); + + let messages = vec![ + Value::String(format!("\"{line1}\"")), + Value::String(format!("\"{line2}\"")), + Value::String(format!("\"{line3}\"")), + Value::String(format!("\"{line4}\"")), + ]; + + let mut text_data = HashMap::new(); + text_data.insert("messages".to_string(), Value::List(messages)); + text_data.insert("color".to_string(), Value::String("black".to_string())); + text_data.insert("has_glowing_text".to_string(), Value::Byte(0)); + + block_entities.insert("front_text".to_string(), Value::Compound(text_data)); + block_entities.insert( + "id".to_string(), + Value::String("minecraft:sign".to_string()), + ); + block_entities.insert("is_waxed".to_string(), Value::Byte(0)); + block_entities.insert("keepPacked".to_string(), Value::Byte(0)); + block_entities.insert("x".to_string(), Value::Int(x)); + block_entities.insert("y".to_string(), Value::Int(absolute_y)); + block_entities.insert("z".to_string(), Value::Int(z)); + + let region = self.world.get_or_create_region(region_x, region_z); + let chunk = region.get_or_create_chunk(chunk_x & 31, chunk_z & 31); + + if let Some(chunk_data) = chunk.other.get_mut("block_entities") { + if let Value::List(entities) = chunk_data { + entities.push(Value::Compound(block_entities)); + } + } else { + chunk.other.insert( + "block_entities".to_string(), + Value::List(vec![Value::Compound(block_entities)]), + ); + } + + self.set_block(SIGN, x, y, z, None, None); + } + + /// Sets a block of the specified type at the given coordinates. + /// + /// Y value is interpreted as an offset from ground level. + #[inline] + pub fn set_block( + &mut self, + block: Block, + x: i32, + y: i32, + z: i32, + override_whitelist: Option<&[Block]>, + override_blacklist: Option<&[Block]>, + ) { + // Check if coordinates are within bounds + if !self.xzbbox.contains(&XZPoint::new(x, z)) { + return; + } + + // Calculate the absolute Y coordinate based on ground level + let absolute_y = self.get_absolute_y(x, y, z); + + let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { + // Check against whitelist and blacklist + if let Some(whitelist) = override_whitelist { + whitelist + .iter() + .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) + } else if let Some(blacklist) = override_blacklist { + !blacklist + .iter() + .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) + } else { + false + } + } else { + true + }; + + if should_insert { + self.world.set_block(x, absolute_y, z, block); + } + } + + /// Sets a block of the specified type at the given coordinates with absolute Y value. + #[inline] + pub fn set_block_absolute( + &mut self, + block: Block, + x: i32, + absolute_y: i32, + z: i32, + override_whitelist: Option<&[Block]>, + override_blacklist: Option<&[Block]>, + ) { + // Check if coordinates are within bounds + if !self.xzbbox.contains(&XZPoint::new(x, z)) { + return; + } + + let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { + // Check against whitelist and blacklist + if let Some(whitelist) = override_whitelist { + whitelist + .iter() + .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) + } else if let Some(blacklist) = override_blacklist { + !blacklist + .iter() + .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) + } else { + false + } + } else { + true + }; + + if should_insert { + self.world.set_block(x, absolute_y, z, block); + } + } + + /// Sets a block with properties at the given coordinates with absolute Y value. + #[inline] + pub fn set_block_with_properties_absolute( + &mut self, + block_with_props: BlockWithProperties, + x: i32, + absolute_y: i32, + z: i32, + override_whitelist: Option<&[Block]>, + override_blacklist: Option<&[Block]>, + ) { + // Check if coordinates are within bounds + if !self.xzbbox.contains(&XZPoint::new(x, z)) { + return; + } + + let should_insert = if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { + // Check against whitelist and blacklist + if let Some(whitelist) = override_whitelist { + whitelist + .iter() + .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) + } else if let Some(blacklist) = override_blacklist { + !blacklist + .iter() + .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) + } else { + false + } + } else { + true + }; + + if should_insert { + self.world + .set_block_with_properties(x, absolute_y, z, block_with_props); + } + } + + /// Fills a cuboid area with the specified block between two coordinates. + #[allow(clippy::too_many_arguments)] + #[inline] + pub fn fill_blocks( + &mut self, + block: Block, + x1: i32, + y1: i32, + z1: i32, + x2: i32, + y2: i32, + z2: i32, + override_whitelist: Option<&[Block]>, + override_blacklist: Option<&[Block]>, + ) { + let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) }; + let (min_y, max_y) = if y1 < y2 { (y1, y2) } else { (y2, y1) }; + let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) }; + + for x in min_x..=max_x { + for y_offset in min_y..=max_y { + for z in min_z..=max_z { + self.set_block( + block, + x, + y_offset, + z, + override_whitelist, + override_blacklist, + ); + } + } + } + } + + /// Fills a cuboid area with the specified block between two coordinates using absolute Y values. + #[allow(clippy::too_many_arguments)] + #[inline] + pub fn fill_blocks_absolute( + &mut self, + block: Block, + x1: i32, + y1_absolute: i32, + z1: i32, + x2: i32, + y2_absolute: i32, + z2: i32, + override_whitelist: Option<&[Block]>, + override_blacklist: Option<&[Block]>, + ) { + let (min_x, max_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) }; + let (min_y, max_y) = if y1_absolute < y2_absolute { + (y1_absolute, y2_absolute) + } else { + (y2_absolute, y1_absolute) + }; + let (min_z, max_z) = if z1 < z2 { (z1, z2) } else { (z2, z1) }; + + for x in min_x..=max_x { + for absolute_y in min_y..=max_y { + for z in min_z..=max_z { + self.set_block_absolute( + block, + x, + absolute_y, + z, + override_whitelist, + override_blacklist, + ); + } + } + } + } + + /// Checks for a block at the given coordinates. + #[inline] + pub fn check_for_block(&self, x: i32, y: i32, z: i32, whitelist: Option<&[Block]>) -> bool { + let absolute_y = self.get_absolute_y(x, y, z); + + // Retrieve the chunk modification map + if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { + if let Some(whitelist) = whitelist { + if whitelist + .iter() + .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) + { + return true; // Block is in the list + } + } + } + false + } + + /// Checks for a block at the given coordinates with absolute Y value. + #[allow(unused)] + pub fn check_for_block_absolute( + &self, + x: i32, + absolute_y: i32, + z: i32, + whitelist: Option<&[Block]>, + blacklist: Option<&[Block]>, + ) -> bool { + // Retrieve the chunk modification map + if let Some(existing_block) = self.world.get_block(x, absolute_y, z) { + // Check against whitelist and blacklist + if let Some(whitelist) = whitelist { + if whitelist + .iter() + .any(|whitelisted_block: &Block| whitelisted_block.id() == existing_block.id()) + { + return true; // Block is in whitelist + } + return false; + } + if let Some(blacklist) = blacklist { + if blacklist + .iter() + .any(|blacklisted_block: &Block| blacklisted_block.id() == existing_block.id()) + { + return true; // Block is in blacklist + } + } + return whitelist.is_none() && blacklist.is_none(); + } + + false + } + + /// Checks if a block exists at the given coordinates with absolute Y value. + /// + /// Unlike `check_for_block_absolute`, this doesn't filter by block type. + #[allow(unused)] + pub fn block_at_absolute(&self, x: i32, absolute_y: i32, z: i32) -> bool { + self.world.get_block(x, absolute_y, z).is_some() + } + + /// Saves all changes made to the world by writing to the appropriate format. + pub fn save(&mut self) { + println!( + "Generating world for: {}", + match self.format { + WorldFormat::JavaAnvil => "Java Edition (Anvil)", + WorldFormat::BedrockMcWorld => "Bedrock Edition (.mcworld)", + } + ); + + match self.format { + WorldFormat::JavaAnvil => self.save_java(), + WorldFormat::BedrockMcWorld => self.save_bedrock(), + } + } + + #[allow(unreachable_code)] + fn save_bedrock(&mut self) { + println!("{} Saving Bedrock world...", "[7/7]".bold()); + emit_gui_progress_update(90.0, "Saving Bedrock world..."); + + #[cfg(feature = "bedrock")] + { + if let Err(error) = self.save_bedrock_internal() { + eprintln!("Failed to save Bedrock world: {error}"); + #[cfg(feature = "gui")] + send_log( + LogLevel::Error, + &format!("Failed to save Bedrock world: {error}"), + ); + } + } + + #[cfg(not(feature = "bedrock"))] + { + eprintln!( + "Bedrock output requested but the 'bedrock' feature is not enabled at build time." + ); + #[cfg(feature = "gui")] + send_log( + LogLevel::Error, + "Bedrock output requested but the 'bedrock' feature is not enabled at build time.", + ); + } + } + + #[cfg(feature = "bedrock")] + fn save_bedrock_internal(&mut self) -> Result<(), BedrockSaveError> { + // Use the stored level name if available, otherwise extract from path + let level_name = self.bedrock_level_name.clone().unwrap_or_else(|| { + self.world_dir + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Arnis World") + .to_string() + }); + + BedrockWriter::new( + self.world_dir.clone(), + level_name, + self.bedrock_spawn_point, + self.ground.clone(), + ) + .write_world(&self.world, self.xzbbox, &self.llbbox) + } + + /// Saves world metadata to a JSON file + pub(crate) fn save_metadata(&mut self) -> Result<(), Box> { + let metadata_path = self.world_dir.join("metadata.json"); + + let mut file = File::create(&metadata_path).map_err(|e| { + format!( + "Failed to create metadata file at {}: {}", + metadata_path.display(), + e + ) + })?; + + let metadata = WorldMetadata { + min_mc_x: self.xzbbox.min_x(), + max_mc_x: self.xzbbox.max_x(), + min_mc_z: self.xzbbox.min_z(), + max_mc_z: self.xzbbox.max_z(), + + min_geo_lat: self.llbbox.min().lat(), + max_geo_lat: self.llbbox.max().lat(), + min_geo_lon: self.llbbox.min().lng(), + max_geo_lon: self.llbbox.max().lng(), + }; + + let contents = serde_json::to_string(&metadata) + .map_err(|e| format!("Failed to serialize metadata to JSON: {}", e))?; + + write!(&mut file, "{}", contents) + .map_err(|e| format!("Failed to write metadata to file: {}", e))?; + + Ok(()) + } +}
Privacy Policy: