From 59d31cfbb896f20a96440a448f0a16fa9552e49e Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:43:13 +0100 Subject: [PATCH 01/39] Add *.mcworld to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3ddeeaa..856457c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /wiki +*.mcworld # Environment files .env From 06ba4db97e1a73c532d29d5489aafea599fee238 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:43:27 +0100 Subject: [PATCH 02/39] Add bedrockrs and dependencies for Bedrock support --- Cargo.lock | 312 ++++++++++++++++++++++++++++++++++++++++++++++++----- Cargo.toml | 9 +- 2 files changed, 291 insertions(+), 30 deletions(-) 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"] } From 02594b1cae6b1b8c5a486fd6ef4a514c01194287 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:43:38 +0100 Subject: [PATCH 03/39] Add bedrock_block_map module import --- src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.rs b/src/main.rs index 7551e6c..f4b4297 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; From 8b3a41b131872adc0644d2f1db7d4a1ed320a8c4 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:43:49 +0100 Subject: [PATCH 04/39] Add Java to Bedrock block mapping --- src/bedrock_block_map.rs | 450 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 src/bedrock_block_map.rs diff --git a/src/bedrock_block_map.rs b/src/bedrock_block_map.rs new file mode 100644 index 0000000..a7f1d22 --- /dev/null +++ b/src/bedrock_block_map.rs @@ -0,0 +1,450 @@ +//! 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. + +#![cfg(feature = "bedrock")] + +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()))], + ), + + // Default: use the same name (works for many blocks) + _ => BedrockBlock::simple(java_name), + } +} + +#[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" + )); + } +} From ee0521f232a61bcd454784fa344df7d39a72a014 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:43:59 +0100 Subject: [PATCH 05/39] Add Bedrock world format support with LevelDB storage --- src/world_editor.rs | 1111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1111 insertions(+) diff --git a/src/world_editor.rs b/src/world_editor.rs index 6e9409e..c761d52 100644 --- a/src/world_editor.rs +++ b/src/world_editor.rs @@ -18,6 +18,13 @@ use std::io::Write; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] // BedrockMcWorld will be used when GUI format toggle is implemented +pub enum WorldFormat { + JavaAnvil, + BedrockMcWorld, +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Chunk { @@ -167,6 +174,60 @@ impl SectionToModify { } } +#[cfg(all(test, feature = "bedrock"))] +mod bedrock_tests { + use super::bedrock_support::BedrockWriter; + use super::WorldToModify; + use crate::coordinate_system::cartesian::XZBBox; + use crate::coordinate_system::geographic::LLBBox; + use serde_json::Value; + use std::fs; + 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()) + .write_world(&world, &xzbbox, &llbbox) + .expect("write_world"); + + let metadata_path = output_dir.join("metadata.json"); + let metadata_bytes = fs::read(&metadata_path).expect("metadata file readable"); + let metadata: Value = serde_json::from_slice(&metadata_bytes).expect("valid metadata JSON"); + + assert_eq!(metadata["format"], "bedrock-mcworld"); + assert_eq!(metadata["chunk_count"], 0); // empty world structure + + let levelname_contents = fs::read_to_string(output_dir.join("levelname.txt")).unwrap(); + assert_eq!(levelname_contents, "test-world"); + + assert!(output_dir.join("db").is_dir(), "db directory created"); + + // Ensure .mcworld archive exists and includes stub files + 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())); + } +} + impl Default for SectionToModify { fn default() -> Self { Self { @@ -328,12 +389,16 @@ pub struct WorldEditor<'a> { 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, } // template // impl for struct WorldEditor {...} impl<'a> WorldEditor<'a> { // Initializes the WorldEditor with the region directory and template region path. + // This is the default constructor used by CLI mode - Java format only pub fn new(world_dir: PathBuf, xzbbox: &'a XZBBox, llbbox: LLBBox) -> Self { Self { world_dir, @@ -341,6 +406,29 @@ impl<'a> WorldEditor<'a> { xzbbox, llbbox, ground: None, + format: WorldFormat::JavaAnvil, + bedrock_level_name: 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)] // Will be used when GUI format toggle is implemented + pub fn new_with_format_and_name( + world_dir: PathBuf, + xzbbox: &'a XZBBox, + llbbox: LLBBox, + format: WorldFormat, + bedrock_level_name: Option, + ) -> Self { + Self { + world_dir, + world: WorldToModify::default(), + xzbbox, + llbbox, + ground: None, + format, + bedrock_level_name, } } @@ -354,6 +442,11 @@ impl<'a> WorldEditor<'a> { self.ground.as_ref().map(|g| g.as_ref()) } + #[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 { @@ -752,6 +845,13 @@ impl<'a> WorldEditor<'a> { /// Saves all changes made to the world by writing modified chunks to the appropriate region files. pub fn save(&mut self) { + match self.format { + WorldFormat::JavaAnvil => self.save_java(), + WorldFormat::BedrockMcWorld => self.save_bedrock(), + } + } + + fn save_java(&mut self) { println!("{} Saving world...", "[7/7]".bold()); emit_gui_progress_update(90.0, "Saving world..."); @@ -910,6 +1010,55 @@ impl<'a> WorldEditor<'a> { save_pb.finish(); } + #[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}"), + ); + } + return; + } + + #[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<(), bedrock_support::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() + }); + + bedrock_support::BedrockWriter::new(self.world_dir.clone(), level_name).write_world( + &self.world, + self.xzbbox, + &self.llbbox, + ) + } + fn save_metadata(&mut self) -> Result<(), Box> { let metadata_path = self.world_dir.join("metadata.json"); @@ -1028,3 +1177,965 @@ fn create_level_wrapper(chunk: &Chunk) -> HashMap { ])), )]) } + +#[cfg(feature = "bedrock")] +mod bedrock_support { + use super::*; + use crate::bedrock_block_map::{to_bedrock_block, BedrockBlock, BedrockBlockStateValue}; + 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; + use std::io::{Cursor, Write as IoWrite}; + use vek::Vec2; + use zip::write::FileOptions; + use zip::CompressionMethod; + use zip::ZipWriter; + + #[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) + } + } + + #[derive(Serialize)] + struct BedrockMetadata { + #[serde(flatten)] + world: WorldMetadata, + format: &'static str, + chunk_count: usize, + } + + /// Bedrock block state for NBT serialization + #[derive(serde::Serialize)] + struct BedrockBlockState { + name: String, + states: StdHashMap, + } + + /// NBT-compatible value types for Bedrock block states + #[derive(serde::Serialize)] + #[serde(untagged)] + enum BedrockNbtValue { + String(String), + Byte(i8), + Int(i32), + } + + impl From<&BedrockBlockStateValue> for BedrockNbtValue { + 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), + } + } + } + + pub struct BedrockWriter { + output_dir: PathBuf, + level_name: String, + } + + impl BedrockWriter { + pub fn new(output_path: PathBuf, level_name: String) -> 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().map_or(false, |ext| ext == "mcworld") { + output_path.with_extension("") + } else { + output_path + }; + + Self { + output_dir, + level_name, + } + } + + pub fn write_world( + &mut self, + world: &WorldToModify, + xzbbox: &XZBBox, + llbbox: &LLBBox, + ) -> Result<(), BedrockSaveError> { + self.prepare_output_dir()?; + self.write_level_name()?; + self.write_level_dat(xzbbox)?; + self.write_chunks_to_db(world)?; + self.write_metadata(world, xzbbox, llbbox)?; + self.package_mcworld()?; + 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) + + let spawn_x = (xzbbox.min_x() + xzbbox.max_x()) / 2; + let spawn_z = (xzbbox.min_z() + xzbbox.max_z()) / 2; + + 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: "Arnis World".to_string(), + random_seed: 0, + + // Spawn location + spawn_x, + spawn_y: 64, + 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, // Disabled since spawnMobs is 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("█▓░"), + ); + + // 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)))?; + } + + progress_bar.inc(1); + } + } + + progress_bar.finish_with_message("Chunks written to LevelDB"); + + // Note: When db goes out of scope, the Drop implementation should flush writes. + // If Bedrock worlds don't work properly, we may need to fork bedrockrs + // to add explicit flush() and compact_all() methods. + drop(db); + + Ok(()) + } + + /// Create Data3D record (heightmap + biomes) + fn create_data3d(&self, _chunk: &ChunkToModify) -> Vec { + // Data3D format: + // - Heightmap: 256 entries * 2 bytes each = 512 bytes (i16 LE for each x,z position) + // - 3D biomes: Variable, but simplified to palette format + + let mut buffer = Vec::with_capacity(540); + + // Heightmap - 256 entries (16x16) as i16 LE + // For now, use a fixed height of 4 (ground level for superflat style) + // This represents the highest non-air block Y coordinate + for _ in 0..256 { + buffer.extend_from_slice(&4i16.to_le_bytes()); + } + + // 3D biome data - simplified to just plains biome (id 1) + // The biome format uses palette encoding similar to blocks + // For simplicity, we write a minimal biome palette + // Format: palette_type (1 byte) + optional palette data + // Using single-value palette (all plains) + + // The reference world has 540 bytes total - 512 for heightmap leaves 28 for biomes + // Let's try a minimal biome encoding + // According to wiki, post-1.18 uses 3D biomes with subchunk granularity + // For now, just pad with zeros to match the expected size + + // Actually, looking at the reference: 04 00 repeated means height = 4 for all positions + // Then biome data follows + + // Let's examine what we need - maybe just 24 sub-biome palette entries + // Each biome subchunk is 4x4x4 = 64 entries + // Using 1 bit per block (2 palette entries) = 64/8 = 8 bytes + 4 byte palette count + NBT + + // For now, create empty biome section - game might generate it + // Just ensure we have some valid data + buffer.extend_from_slice(&[0u8; 28]); // Padding to ~540 bytes + + 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 + blocks_per_word - 1) / blocks_per_word; // Ceiling division + 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()) + } + + /// Build a palette and index array from a section + /// Converts from internal YZX ordering to Bedrock's XZY ordering + 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 + 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); + + // Process all blocks with coordinate conversion + // Internal storage: Y * 256 + Z * 16 + X (YZX) + // Bedrock storage (from Chunker PaletteUtil.java writeChunkPalette): + // For index i: x = (i >> 8) & 0xF, z = (i >> 4) & 0xF, y = i & 0xF + // So: bedrock_idx = x * 256 + z * 16 + y (XZY) + // + // Chunker stores blocks as values[x][y][z] and reads with values[x][y][z] + // where x, y, z are extracted from index i as shown above. + // + // Internal YZX: internal_idx = y*256 + z*16 + x + // Bedrock XZY: bedrock_idx = x*256 + z*16 + y + for x in 0..16usize { + for z in 0..16usize { + for y in 0..16usize { + // Read from internal order: y*256 + z*16 + x + let internal_idx = y * 256 + z * 16 + x; + let block = section.blocks[internal_idx]; + + let bedrock_block = to_bedrock_block(block); + 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 + }; + + // Write to Bedrock order: x*256 + z*16 + y + 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 assets + let icon_path = std::path::Path::new("assets/minecraft/world_icon.jpeg"); + if icon_path.exists() { + writer.start_file("world_icon.jpeg", options)?; + let contents = fs::read(icon_path)?; + writer.write_all(&contents)?; + } + + // Add db directory and its contents + let db_path = self.output_dir.join("db"); + if db_path.is_dir() { + self.add_directory_to_zip(&mut writer, &db_path, "db", options)?; + } + + writer.finish()?; + Ok(()) + } + + fn add_directory_to_zip( + &self, + 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() { + self.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} + 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, + } +} + From d4fd9b9cd38037d837d2d077f84d337476d5b036 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:44:19 +0100 Subject: [PATCH 06/39] Add GenerationOptions and format-aware world generation --- src/data_processing.rs | 104 +++++++++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/src/data_processing.rs b/src/data_processing.rs index 5e54470..722a28d 100644 --- a/src/data_processing.rs +++ b/src/data_processing.rs @@ -6,15 +6,24 @@ 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 fn generate_world( elements: Vec, xzbbox: XZBBox, @@ -22,7 +31,33 @@ 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, + }; + 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.clone(), + options.format, + options.level_name, + ); println!("{} Processing data...", "[4/7]".bold()); @@ -266,33 +301,42 @@ pub fn generate_world( emit_gui_progress_update(100.0, "Done! World generation completed."); println!("{}", "Done! World generation completed.".green().bold()); - // 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) + if world_format == WorldFormat::JavaAnvil { + 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) } From 4a326c3dadcefa21039d633004f39e2fd742d649 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:44:37 +0100 Subject: [PATCH 07/39] Add emit_open_mcworld_file event --- src/progress.rs | 9 +++++++++ 1 file changed, 9 insertions(+) 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); + } + } +} From 127a0e5e68b40a1c8616e31e258a7c7367c3078c Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:44:46 +0100 Subject: [PATCH 08/39] Add GUI format toggle and show_in_folder command --- src/gui.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index 242c1e8..b0ca609 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -2,7 +2,7 @@ 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; @@ -10,6 +10,7 @@ use crate::progress; use crate::retrieve_data; use crate::telemetry::{self, send_log, LogLevel}; use crate::version_check; +use crate::world_editor::WorldFormat; use fastnbt::Value; use flate2::read::GzDecoder; use fs2::FileExt; @@ -60,6 +61,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 +113,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 +734,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 +802,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; @@ -803,19 +868,50 @@ 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)) + } + }; + + // Create generation options + let generation_options = GenerationOptions { + path: generation_path.clone(), + format: world_format, + level_name, + }; + + // 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,12 +935,13 @@ 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 return Ok(()); @@ -877,12 +974,13 @@ 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 Ok(()) From 6d164102ad1df65ae0156064ac241974b5b61e28 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:45:35 +0100 Subject: [PATCH 09/39] Add Java/Bedrock format toggle UI --- src/gui/index.html | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/gui/index.html b/src/gui/index.html index a216db1..c65590c 100644 --- a/src/gui/index.html +++ b/src/gui/index.html @@ -37,15 +37,26 @@

Select World

- -
- + +
+
+ +
+ +
+ + +
From 8bb779d6cce80034e6592e4fe9108ae25e006f4c Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:45:47 +0100 Subject: [PATCH 10/39] Add format toggle button styles --- src/gui/css/styles.css | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) 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; From a03318bb98003d302d6d5f476e2eaa3f9a7318ef Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:45:59 +0100 Subject: [PATCH 11/39] Add format toggle logic and mcworld file opening --- src/gui/js/main.js | 56 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/gui/js/main.js b/src/gui/js/main.js index 8f53bb2..b8fd3fd 100644 --- a/src/gui/js/main.js +++ b/src/gui/js/main.js @@ -220,6 +220,18 @@ 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; + console.log("Showing mcworld file in folder:", filePath); + 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() { @@ -312,6 +324,9 @@ function initSettings() { } }); + // World format toggle (Java/Bedrock) + initWorldFormatToggle(); + // Telemetry consent toggle const telemetryToggle = document.getElementById("telemetry-toggle"); const telemetryKey = 'telemetry-consent'; @@ -350,6 +365,44 @@ 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'); + + if (format === 'java') { + javaBtn.classList.add('format-active'); + bedrockBtn.classList.remove('format-active'); + } else { + javaBtn.classList.remove('format-active'); + bedrockBtn.classList.add('format-active'); + } +} + +// Expose to window for onclick handlers +window.setWorldFormat = setWorldFormat; + // Telemetry consent (first run only) function initTelemetryConsent() { const key = 'telemetry-consent'; // values: 'true' | 'false' @@ -735,7 +788,8 @@ async function startGeneration() { fillgroundEnabled: fill_ground, isNewWorld: isNewWorld, spawnPoint: spawnPoint, - telemetryConsent: telemetryConsent || false + telemetryConsent: telemetryConsent || false, + worldFormat: selectedWorldFormat }); console.log("Generation process started."); From b34cbf4307fd253df4c045e1fcab2d257010eebf Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:46:22 +0100 Subject: [PATCH 12/39] Add bedrock-rs license credit --- src/gui/js/license.js | 4 ++++ 1 file changed, 4 insertions(+) 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. From f1b37fbbb6b13bd792f9427f04184b551fb2b272 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:46:50 +0100 Subject: [PATCH 13/39] Add Bedrock world icon asset --- assets/minecraft/world_icon.jpeg | Bin 0 -> 54900 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/minecraft/world_icon.jpeg diff --git a/assets/minecraft/world_icon.jpeg b/assets/minecraft/world_icon.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a741cf571da88d3a3df9dcfd215fff0905cc8c16 GIT binary patch literal 54900 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 z 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 Date: Thu, 4 Dec 2025 16:52:07 +0100 Subject: [PATCH 14/39] Clean up temp directory after packaging mcworld --- src/world_editor.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/world_editor.rs b/src/world_editor.rs index c761d52..494b6cc 100644 --- a/src/world_editor.rs +++ b/src/world_editor.rs @@ -1315,6 +1315,7 @@ mod bedrock_support { self.write_chunks_to_db(world)?; self.write_metadata(world, xzbbox, llbbox)?; self.package_mcworld()?; + self.cleanup_temp_dir()?; Ok(()) } @@ -1829,6 +1830,14 @@ mod bedrock_support { 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( &self, writer: &mut ZipWriter, From 958dc2107e733e5f0b1ffc6d79608147af74fa21 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:25:14 +0100 Subject: [PATCH 15/39] Remove console log line --- src/gui/js/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gui/js/main.js b/src/gui/js/main.js index b8fd3fd..1f3bdbb 100644 --- a/src/gui/js/main.js +++ b/src/gui/js/main.js @@ -224,7 +224,6 @@ function setupProgressListener() { // 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; - console.log("Showing mcworld file in folder:", filePath); try { // Use our custom command to show the file in the system file explorer await invoke("gui_show_in_folder", { path: filePath }); From 5b5e93b89a06fd4d3d420da5f20124bbdeaddd2f Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:26:12 +0100 Subject: [PATCH 16/39] Refactor world_editor into modular directory structure --- src/world_editor.rs | 2150 ----------------------------------- src/world_editor/bedrock.rs | 1028 +++++++++++++++++ src/world_editor/common.rs | 312 +++++ src/world_editor/java.rs | 323 ++++++ src/world_editor/mod.rs | 578 ++++++++++ 5 files changed, 2241 insertions(+), 2150 deletions(-) delete mode 100644 src/world_editor.rs create mode 100644 src/world_editor/bedrock.rs create mode 100644 src/world_editor/common.rs create mode 100644 src/world_editor/java.rs create mode 100644 src/world_editor/mod.rs diff --git a/src/world_editor.rs b/src/world_editor.rs deleted file mode 100644 index 494b6cc..0000000 --- a/src/world_editor.rs +++ /dev/null @@ -1,2150 +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(Clone, Copy, Debug, Eq, PartialEq)] -#[allow(dead_code)] // BedrockMcWorld will be used when GUI format toggle is implemented -pub enum WorldFormat { - JavaAnvil, - BedrockMcWorld, -} - -#[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(), - } - } -} - -#[cfg(all(test, feature = "bedrock"))] -mod bedrock_tests { - use super::bedrock_support::BedrockWriter; - use super::WorldToModify; - use crate::coordinate_system::cartesian::XZBBox; - use crate::coordinate_system::geographic::LLBBox; - use serde_json::Value; - use std::fs; - 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()) - .write_world(&world, &xzbbox, &llbbox) - .expect("write_world"); - - let metadata_path = output_dir.join("metadata.json"); - let metadata_bytes = fs::read(&metadata_path).expect("metadata file readable"); - let metadata: Value = serde_json::from_slice(&metadata_bytes).expect("valid metadata JSON"); - - assert_eq!(metadata["format"], "bedrock-mcworld"); - assert_eq!(metadata["chunk_count"], 0); // empty world structure - - let levelname_contents = fs::read_to_string(output_dir.join("levelname.txt")).unwrap(); - assert_eq!(levelname_contents, "test-world"); - - assert!(output_dir.join("db").is_dir(), "db directory created"); - - // Ensure .mcworld archive exists and includes stub files - 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())); - } -} - -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>, - format: WorldFormat, - /// Optional level name for Bedrock worlds (e.g., "Arnis World: New York City") - bedrock_level_name: Option, -} - -// template -// impl for struct WorldEditor {...} -impl<'a> WorldEditor<'a> { - // Initializes the WorldEditor with the region directory and template region path. - // This is the default constructor used by CLI mode - Java format only - 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, - } - } - - /// 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)] // Will be used when GUI format toggle is implemented - pub fn new_with_format_and_name( - world_dir: PathBuf, - xzbbox: &'a XZBBox, - llbbox: LLBBox, - format: WorldFormat, - bedrock_level_name: Option, - ) -> Self { - Self { - world_dir, - world: WorldToModify::default(), - xzbbox, - llbbox, - ground: None, - format, - bedrock_level_name, - } - } - - /// 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()) - } - - #[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 - } - } - - /// 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) { - match self.format { - WorldFormat::JavaAnvil => self.save_java(), - WorldFormat::BedrockMcWorld => self.save_bedrock(), - } - } - - 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 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(); - } - - #[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}"), - ); - } - return; - } - - #[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<(), bedrock_support::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() - }); - - bedrock_support::BedrockWriter::new(self.world_dir.clone(), level_name).write_world( - &self.world, - self.xzbbox, - &self.llbbox, - ) - } - - 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(), - ), - ), - ])), - )]) -} - -#[cfg(feature = "bedrock")] -mod bedrock_support { - use super::*; - use crate::bedrock_block_map::{to_bedrock_block, BedrockBlock, BedrockBlockStateValue}; - 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; - use std::io::{Cursor, Write as IoWrite}; - use vek::Vec2; - use zip::write::FileOptions; - use zip::CompressionMethod; - use zip::ZipWriter; - - #[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) - } - } - - #[derive(Serialize)] - struct BedrockMetadata { - #[serde(flatten)] - world: WorldMetadata, - format: &'static str, - chunk_count: usize, - } - - /// Bedrock block state for NBT serialization - #[derive(serde::Serialize)] - struct BedrockBlockState { - name: String, - states: StdHashMap, - } - - /// NBT-compatible value types for Bedrock block states - #[derive(serde::Serialize)] - #[serde(untagged)] - enum BedrockNbtValue { - String(String), - Byte(i8), - Int(i32), - } - - impl From<&BedrockBlockStateValue> for BedrockNbtValue { - 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), - } - } - } - - pub struct BedrockWriter { - output_dir: PathBuf, - level_name: String, - } - - impl BedrockWriter { - pub fn new(output_path: PathBuf, level_name: String) -> 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().map_or(false, |ext| ext == "mcworld") { - output_path.with_extension("") - } else { - output_path - }; - - Self { - output_dir, - level_name, - } - } - - pub fn write_world( - &mut self, - world: &WorldToModify, - xzbbox: &XZBBox, - llbbox: &LLBBox, - ) -> Result<(), BedrockSaveError> { - self.prepare_output_dir()?; - self.write_level_name()?; - self.write_level_dat(xzbbox)?; - self.write_chunks_to_db(world)?; - self.write_metadata(world, xzbbox, llbbox)?; - self.package_mcworld()?; - 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) - - let spawn_x = (xzbbox.min_x() + xzbbox.max_x()) / 2; - let spawn_z = (xzbbox.min_z() + xzbbox.max_z()) / 2; - - 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: "Arnis World".to_string(), - random_seed: 0, - - // Spawn location - spawn_x, - spawn_y: 64, - 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, // Disabled since spawnMobs is 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("█▓░"), - ); - - // 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)))?; - } - - progress_bar.inc(1); - } - } - - progress_bar.finish_with_message("Chunks written to LevelDB"); - - // Note: When db goes out of scope, the Drop implementation should flush writes. - // If Bedrock worlds don't work properly, we may need to fork bedrockrs - // to add explicit flush() and compact_all() methods. - drop(db); - - Ok(()) - } - - /// Create Data3D record (heightmap + biomes) - fn create_data3d(&self, _chunk: &ChunkToModify) -> Vec { - // Data3D format: - // - Heightmap: 256 entries * 2 bytes each = 512 bytes (i16 LE for each x,z position) - // - 3D biomes: Variable, but simplified to palette format - - let mut buffer = Vec::with_capacity(540); - - // Heightmap - 256 entries (16x16) as i16 LE - // For now, use a fixed height of 4 (ground level for superflat style) - // This represents the highest non-air block Y coordinate - for _ in 0..256 { - buffer.extend_from_slice(&4i16.to_le_bytes()); - } - - // 3D biome data - simplified to just plains biome (id 1) - // The biome format uses palette encoding similar to blocks - // For simplicity, we write a minimal biome palette - // Format: palette_type (1 byte) + optional palette data - // Using single-value palette (all plains) - - // The reference world has 540 bytes total - 512 for heightmap leaves 28 for biomes - // Let's try a minimal biome encoding - // According to wiki, post-1.18 uses 3D biomes with subchunk granularity - // For now, just pad with zeros to match the expected size - - // Actually, looking at the reference: 04 00 repeated means height = 4 for all positions - // Then biome data follows - - // Let's examine what we need - maybe just 24 sub-biome palette entries - // Each biome subchunk is 4x4x4 = 64 entries - // Using 1 bit per block (2 palette entries) = 64/8 = 8 bytes + 4 byte palette count + NBT - - // For now, create empty biome section - game might generate it - // Just ensure we have some valid data - buffer.extend_from_slice(&[0u8; 28]); // Padding to ~540 bytes - - 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 + blocks_per_word - 1) / blocks_per_word; // Ceiling division - 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()) - } - - /// Build a palette and index array from a section - /// Converts from internal YZX ordering to Bedrock's XZY ordering - 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 - 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); - - // Process all blocks with coordinate conversion - // Internal storage: Y * 256 + Z * 16 + X (YZX) - // Bedrock storage (from Chunker PaletteUtil.java writeChunkPalette): - // For index i: x = (i >> 8) & 0xF, z = (i >> 4) & 0xF, y = i & 0xF - // So: bedrock_idx = x * 256 + z * 16 + y (XZY) - // - // Chunker stores blocks as values[x][y][z] and reads with values[x][y][z] - // where x, y, z are extracted from index i as shown above. - // - // Internal YZX: internal_idx = y*256 + z*16 + x - // Bedrock XZY: bedrock_idx = x*256 + z*16 + y - for x in 0..16usize { - for z in 0..16usize { - for y in 0..16usize { - // Read from internal order: y*256 + z*16 + x - let internal_idx = y * 256 + z * 16 + x; - let block = section.blocks[internal_idx]; - - let bedrock_block = to_bedrock_block(block); - 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 - }; - - // Write to Bedrock order: x*256 + z*16 + y - 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 assets - let icon_path = std::path::Path::new("assets/minecraft/world_icon.jpeg"); - if icon_path.exists() { - writer.start_file("world_icon.jpeg", options)?; - let contents = fs::read(icon_path)?; - writer.write_all(&contents)?; - } - - // Add db directory and its contents - let db_path = self.output_dir.join("db"); - if db_path.is_dir() { - self.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( - &self, - 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() { - self.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} - 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, - } -} - diff --git a/src/world_editor/bedrock.rs b/src/world_editor/bedrock.rs new file mode 100644 index 0000000..52f4093 --- /dev/null +++ b/src/world_editor/bedrock.rs @@ -0,0 +1,1028 @@ +//! 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, BedrockBlock, BedrockBlockStateValue}; +use crate::coordinate_system::cartesian::XZBBox; +use crate::coordinate_system::geographic::LLBBox; + +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, +} + +impl BedrockWriter { + /// Creates a new BedrockWriter + pub fn new(output_path: PathBuf, level_name: String) -> 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().map_or(false, |ext| ext == "mcworld") { + output_path.with_extension("") + } else { + output_path + }; + + Self { + output_dir, + level_name, + } + } + + /// 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()?; + self.write_level_dat(xzbbox)?; + self.write_chunks_to_db(world)?; + self.write_metadata(world, xzbbox, llbbox)?; + self.package_mcworld()?; + 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) + + let spawn_x = (xzbbox.min_x() + xzbbox.max_x()) / 2; + let spawn_z = (xzbbox.min_z() + xzbbox.max_z()) / 2; + + 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: "Arnis World".to_string(), + random_seed: 0, + + // Spawn location + spawn_x, + spawn_y: 64, + 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, // Disabled since spawnMobs is 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("█▓░"), + ); + + // 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)))?; + } + + progress_bar.inc(1); + } + } + + progress_bar.finish_with_message("Chunks written to LevelDB"); + + // Note: When db goes out of scope, the Drop implementation should flush writes. + // If Bedrock worlds don't work properly, we may need to fork bedrockrs + // to add explicit flush() and compact_all() methods. + drop(db); + + Ok(()) + } + + /// Create Data3D record (heightmap + biomes) + fn create_data3d(&self, _chunk: &ChunkToModify) -> Vec { + // Data3D format: + // - Heightmap: 256 entries * 2 bytes each = 512 bytes (i16 LE for each x,z position) + // - 3D biomes: Variable, but simplified to palette format + + let mut buffer = Vec::with_capacity(540); + + // Heightmap - 256 entries (16x16) as i16 LE + // For now, use a fixed height of 4 (ground level for superflat style) + // This represents the highest non-air block Y coordinate + for _ in 0..256 { + buffer.extend_from_slice(&4i16.to_le_bytes()); + } + + // 3D biome data - simplified to just plains biome (id 1) + // The biome format uses palette encoding similar to blocks + // For simplicity, we write a minimal biome palette + // Format: palette_type (1 byte) + optional palette data + // Using single-value palette (all plains) + + // The reference world has 540 bytes total - 512 for heightmap leaves 28 for biomes + // Let's try a minimal biome encoding + // According to wiki, post-1.18 uses 3D biomes with subchunk granularity + // For now, just pad with zeros to match the expected size + + // Actually, looking at the reference: 04 00 repeated means height = 4 for all positions + // Then biome data follows + + // Let's examine what we need - maybe just 24 sub-biome palette entries + // Each biome subchunk is 4x4x4 = 64 entries + // Using 1 bit per block (2 palette entries) = 64/8 = 8 bytes + 4 byte palette count + NBT + + // For now, create empty biome section - game might generate it + // Just ensure we have some valid data + buffer.extend_from_slice(&[0u8; 28]); // Padding to ~540 bytes + + 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 + blocks_per_word - 1) / blocks_per_word; // Ceiling division + 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()) + } + + /// Build a palette and index array from a section + /// Converts from internal YZX ordering to Bedrock's XZY ordering + 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 + 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); + + // Process all blocks with coordinate conversion + // Internal storage: Y * 256 + Z * 16 + X (YZX) + // Bedrock storage (from Chunker PaletteUtil.java writeChunkPalette): + // For index i: x = (i >> 8) & 0xF, z = (i >> 4) & 0xF, y = i & 0xF + // So: bedrock_idx = x * 256 + z * 16 + y (XZY) + // + // Chunker stores blocks as values[x][y][z] and reads with values[x][y][z] + // where x, y, z are extracted from index i as shown above. + // + // Internal YZX: internal_idx = y*256 + z*16 + x + // Bedrock XZY: bedrock_idx = x*256 + z*16 + y + for x in 0..16usize { + for z in 0..16usize { + for y in 0..16usize { + // Read from internal order: y*256 + z*16 + x + let internal_idx = y * 256 + z * 16 + x; + let block = section.blocks[internal_idx]; + + let bedrock_block = to_bedrock_block(block); + 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 + }; + + // Write to Bedrock order: x*256 + z*16 + y + 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 assets + let icon_path = std::path::Path::new("assets/minecraft/world_icon.jpeg"); + if icon_path.exists() { + writer.start_file("world_icon.jpeg", options)?; + let contents = fs::read(icon_path)?; + writer.write_all(&contents)?; + } + + // Add db directory and its contents + let db_path = self.output_dir.join("db"); + if db_path.is_dir() { + self.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( + &self, + 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() { + self.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()) + .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 + } +} 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..babcc11 --- /dev/null +++ b/src/world_editor/java.rs @@ -0,0 +1,323 @@ +//! 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 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(); + } +} + +/// 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 + // 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/mod.rs b/src/world_editor/mod.rs new file mode 100644 index 0000000..2cd02a1 --- /dev/null +++ b/src/world_editor/mod.rs @@ -0,0 +1,578 @@ +//! 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. +/// +/// # Lifetime Parameter +/// +/// The lifetime `'a` is tied to the `XZBBox` reference, which defines the +/// world boundaries. This is similar to a C++ template: +/// ```cpp +/// template +/// struct WorldEditor { const XZBBox& xzbbox; } +/// ``` +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, +} + +impl<'a> WorldEditor<'a> { + /// Creates a new WorldEditor with Java Anvil format (default). + /// + /// This is the default constructor used by CLI mode. + 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, + } + } + + /// 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, + ) -> Self { + Self { + world_dir, + world: WorldToModify::default(), + xzbbox, + llbbox, + ground: None, + format, + bedrock_level_name, + } + } + + /// 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) { + 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}"), + ); + } + return; + } + + #[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).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(()) + } +} From 0bb39b7d9ef1bf9b3a68d1fd4ff452cff0b5c5ee Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:32:53 +0100 Subject: [PATCH 17/39] Simplify comments --- src/world_editor/bedrock.rs | 62 +++++++++---------------------------- src/world_editor/java.rs | 4 +-- src/world_editor/mod.rs | 10 ++---- 3 files changed, 18 insertions(+), 58 deletions(-) diff --git a/src/world_editor/bedrock.rs b/src/world_editor/bedrock.rs index 52f4093..ae54cec 100644 --- a/src/world_editor/bedrock.rs +++ b/src/world_editor/bedrock.rs @@ -420,50 +420,25 @@ impl BedrockWriter { progress_bar.finish_with_message("Chunks written to LevelDB"); - // Note: When db goes out of scope, the Drop implementation should flush writes. - // If Bedrock worlds don't work properly, we may need to fork bedrockrs - // to add explicit flush() and compact_all() methods. + // LevelDB writes are flushed when the database is dropped drop(db); Ok(()) } - /// Create Data3D record (heightmap + biomes) + /// 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 { - // Data3D format: - // - Heightmap: 256 entries * 2 bytes each = 512 bytes (i16 LE for each x,z position) - // - 3D biomes: Variable, but simplified to palette format - let mut buffer = Vec::with_capacity(540); - // Heightmap - 256 entries (16x16) as i16 LE - // For now, use a fixed height of 4 (ground level for superflat style) - // This represents the highest non-air block Y coordinate + // 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()); } - // 3D biome data - simplified to just plains biome (id 1) - // The biome format uses palette encoding similar to blocks - // For simplicity, we write a minimal biome palette - // Format: palette_type (1 byte) + optional palette data - // Using single-value palette (all plains) - - // The reference world has 540 bytes total - 512 for heightmap leaves 28 for biomes - // Let's try a minimal biome encoding - // According to wiki, post-1.18 uses 3D biomes with subchunk granularity - // For now, just pad with zeros to match the expected size - - // Actually, looking at the reference: 04 00 repeated means height = 4 for all positions - // Then biome data follows - - // Let's examine what we need - maybe just 24 sub-biome palette entries - // Each biome subchunk is 4x4x4 = 64 entries - // Using 1 bit per block (2 palette entries) = 64/8 = 8 bytes + 4 byte palette count + NBT - - // For now, create empty biome section - game might generate it - // Just ensure we have some valid data - buffer.extend_from_slice(&[0u8; 28]); // Padding to ~540 bytes + // Minimal biome data padding (biomes will be regenerated by the game) + buffer.extend_from_slice(&[0u8; 28]); buffer } @@ -540,8 +515,11 @@ impl BedrockWriter { Ok(buffer.into_inner()) } - /// Build a palette and index array from a section - /// Converts from internal YZX ordering to Bedrock's XZY ordering + /// 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 fn build_palette_and_indices( &self, section: &SectionToModify, @@ -550,27 +528,16 @@ impl BedrockWriter { let mut palette_map: StdHashMap = StdHashMap::new(); let mut indices = [0u16; 4096]; - // Add air as first palette entry + // 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); - // Process all blocks with coordinate conversion - // Internal storage: Y * 256 + Z * 16 + X (YZX) - // Bedrock storage (from Chunker PaletteUtil.java writeChunkPalette): - // For index i: x = (i >> 8) & 0xF, z = (i >> 4) & 0xF, y = i & 0xF - // So: bedrock_idx = x * 256 + z * 16 + y (XZY) - // - // Chunker stores blocks as values[x][y][z] and reads with values[x][y][z] - // where x, y, z are extracted from index i as shown above. - // - // Internal YZX: internal_idx = y*256 + z*16 + x - // Bedrock XZY: bedrock_idx = x*256 + z*16 + y + // Convert blocks from internal YZX to Bedrock XZY ordering for x in 0..16usize { for z in 0..16usize { for y in 0..16usize { - // Read from internal order: y*256 + z*16 + x let internal_idx = y * 256 + z * 16 + x; let block = section.blocks[internal_idx]; @@ -586,7 +553,6 @@ impl BedrockWriter { idx }; - // Write to Bedrock order: x*256 + z*16 + y let bedrock_idx = x * 256 + z * 16 + y; indices[bedrock_idx] = palette_index; } diff --git a/src/world_editor/java.rs b/src/world_editor/java.rs index babcc11..05c8a44 100644 --- a/src/world_editor/java.rs +++ b/src/world_editor/java.rs @@ -299,8 +299,8 @@ fn create_level_wrapper(chunk: &Chunk) -> HashMap { ), )]); - // only add the `data` attribute if it's non-empty - // some software (cough cough dynmap) chokes otherwise + // 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( diff --git a/src/world_editor/mod.rs b/src/world_editor/mod.rs index 2cd02a1..f66f1be 100644 --- a/src/world_editor/mod.rs +++ b/src/world_editor/mod.rs @@ -64,14 +64,8 @@ pub(crate) struct WorldMetadata { /// The main world editor struct for placing blocks and saving worlds. /// -/// # Lifetime Parameter -/// -/// The lifetime `'a` is tied to the `XZBBox` reference, which defines the -/// world boundaries. This is similar to a C++ template: -/// ```cpp -/// template -/// struct WorldEditor { const XZBBox& xzbbox; } -/// ``` +/// 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, From af0ace422fd587ef86919afec981f78467ff3499 Mon Sep 17 00:00:00 2001 From: louis-e <44675238+louis-e@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:04:59 +0100 Subject: [PATCH 18/39] Add bedrock_use_java localization --- src/gui/index.html | 2 +- src/gui/js/main.js | 29 +++++++++++++++++++++++++++++ src/gui/locales/ar.json | 3 ++- src/gui/locales/de.json | 3 ++- src/gui/locales/en-US.json | 3 ++- src/gui/locales/es.json | 3 ++- src/gui/locales/fi.json | 3 ++- src/gui/locales/fr-FR.json | 3 ++- src/gui/locales/hu.json | 3 ++- src/gui/locales/ko.json | 3 ++- src/gui/locales/lt.json | 3 ++- src/gui/locales/lv.json | 3 ++- src/gui/locales/pl.json | 3 ++- src/gui/locales/ru.json | 3 ++- src/gui/locales/sv.json | 3 ++- src/gui/locales/ua.json | 3 ++- src/gui/locales/zh-CN.json | 3 ++- src/world_editor/bedrock.rs | 2 +- src/world_editor/mod.rs | 5 +++++ 19 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/gui/index.html b/src/gui/index.html index c65590c..bcdd7ac 100644 --- a/src/gui/index.html +++ b/src/gui/index.html @@ -40,7 +40,7 @@
-