diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 118f712..4371734 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,18 +1,27 @@
-name: Core checks
+name: MVP checks
on:
push:
pull_request:
permissions:
contents: read
jobs:
- storage:
+ mvp:
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.96.0
components: rustfmt, clippy
- - name: Verify storage and crash recovery
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - name: Verify core, server, client and crash recovery
run: bash scripts/verify.sh
-
+ - name: Install independent MCP test client
+ run: npm install --prefix artifacts/mcp-sdk --no-package-lock @modelcontextprotocol/sdk@1.30.0
+ - name: Verify real stdio MCP interoperability
+ env:
+ SHACRAFT_MCP_SDK: ${{ github.workspace }}/artifacts/mcp-sdk/node_modules/@modelcontextprotocol/sdk
+ run: node scripts/check_mcp.mjs --binary target/debug/shacraft-server --output artifacts/mcp-sdk.json
diff --git a/.gitignore b/.gitignore
index 6598225..f778840 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ __pycache__/
*.pyc
/graphify-out/
+
+/logs/
diff --git a/Cargo.lock b/Cargo.lock
index d65e3c5..099bc83 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "anstream"
version = "1.0.0"
@@ -38,7 +44,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -58,6 +64,79 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "base64",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sha1",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -79,6 +158,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
[[package]]
name = "cc"
version = "1.4.6"
@@ -89,12 +174,35 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "clap"
version = "4.6.6"
@@ -126,7 +234,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.5",
]
[[package]]
@@ -150,6 +258,24 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -160,6 +286,12 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -170,6 +302,23 @@ dependencies = [
"crypto-common",
]
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "errno"
version = "0.3.14"
@@ -177,7 +326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -198,18 +347,53 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
[[package]]
name = "fs2"
version = "0.4.3"
@@ -220,6 +404,67 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -230,6 +475,31 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -237,8 +507,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
]
[[package]]
@@ -247,7 +529,7 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -256,7 +538,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -274,6 +556,237 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -293,15 +806,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
+ "futures-util",
"wasm-bindgen",
]
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
@@ -319,12 +845,101 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "lz4_flex"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
+dependencies = [
+ "twox-hash",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -337,12 +952,78 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -352,6 +1033,62 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -361,12 +1098,136 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
@@ -383,7 +1244,7 @@ version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -392,17 +1253,58 @@ dependencies = [
"sqlite-wasm-rs",
]
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
]
[[package]]
@@ -411,6 +1313,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "serde"
version = "1.0.229"
@@ -438,7 +1352,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.5",
]
[[package]]
@@ -454,6 +1368,40 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -461,10 +1409,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
+[[package]]
+name = "shacraft-compat"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cesu8",
+ "clap",
+ "flate2",
+ "lz4_flex",
+ "rusqlite",
+ "rustix",
+ "serde",
+ "serde_json",
+ "sha2",
+ "shacraft-core",
+ "tempfile",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "shacraft-content"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "flate2",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+]
+
[[package]]
name = "shacraft-core"
version = "0.1.0"
@@ -478,6 +1457,40 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "shacraft-mcp"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "reqwest",
+ "serde_json",
+]
+
+[[package]]
+name = "shacraft-server"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "axum",
+ "base64",
+ "clap",
+ "futures-util",
+ "png",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha2",
+ "shacraft-content",
+ "shacraft-core",
+ "tempfile",
+ "tokio",
+ "tower",
+ "tower-http",
+ "uuid",
+ "wasmi",
+]
+
[[package]]
name = "shacraft-tools"
version = "0.1.0"
@@ -495,12 +1508,50 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
[[package]]
name = "smallvec"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
@@ -513,12 +1564,45 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string-interner"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
+dependencies = [
+ "hashbrown 0.15.5",
+ "serde",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "syn"
version = "3.0.5"
@@ -530,6 +1614,26 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -537,10 +1641,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -560,27 +1664,259 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.5",
]
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "sha1",
+ "thiserror",
+]
+
+[[package]]
+name = "twox-hash"
+version = "2.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
+
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "uuid"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -593,6 +1929,30 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
@@ -606,6 +1966,16 @@ dependencies = [
"wasm-bindgen-shared",
]
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
@@ -625,7 +1995,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.5",
"wasm-bindgen-shared",
]
@@ -638,6 +2008,128 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.259.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.259.0",
+]
+
+[[package]]
+name = "wasmi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
+dependencies = [
+ "spin",
+ "wasmi_collections",
+ "wasmi_core",
+ "wasmi_ir",
+ "wasmparser 0.228.0",
+ "wat",
+]
+
+[[package]]
+name = "wasmi_collections"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
+dependencies = [
+ "string-interner",
+]
+
+[[package]]
+name = "wasmi_core"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "wasmi_ir"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
+dependencies = [
+ "wasmi_core",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.228.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
+dependencies = [
+ "bitflags 2.13.2",
+ "indexmap",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.259.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694"
+dependencies = [
+ "bitflags 2.13.2",
+ "indexmap",
+]
+
+[[package]]
+name = "wast"
+version = "259.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c69beba8d9da07af9a0971b559149a2ac4729895b55144de4fddbf5a02660648"
+dependencies = [
+ "bumpalo",
+ "leb128fmt",
+ "memchr",
+ "unicode-width",
+ "wasm-encoder",
+]
+
+[[package]]
+name = "wat"
+version = "1.259.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6eec44b0c80391b20fb7ad9ea440c72c5d382385bdf74444bb18321c425017e"
+dependencies = [
+ "wast",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -666,6 +2158,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -675,6 +2176,197 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
index c7ec1a4..c097e8b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
-members = ["crates/shacraft-core", "crates/shacraft-tools"]
+members = ["crates/shacraft-core", "crates/shacraft-tools", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-content", "crates/shacraft-compat"]
[workspace.package]
version = "0.1.0"
diff --git a/README.md b/README.md
index c79efb4..eda4069 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,60 @@
# Shacraft Core
-Самостоятельный открытый воксельный движок на Rust. Главный приоритет — низкое и предсказуемое потребление **серверной оперативной памяти**. Сервер Shacraft служит первым практическим применением; ядро не привязано к его мирам, аккаунтам и минииграм.
+Самостоятельный открытый воксельный движок на Rust и работающий локальный MVP: сервер с несколькими мирами, собственный WebGL2-клиент, Spleef, отдельный MCP, каталог Java 26.2, пакеты расширений и конвертер миров. Приоритет — измеренное потребление **серверной RAM**.
-Проект начинается заново локально: исходники из зависшей облачной среды не восстановлены. Сведения о ранее выполненных сборках и тестах не являются результатами этого репозитория.
+## Запустить
-## Текущий результат: этап 1 — хранилище
-
-Реализованы самостоятельная Rust-библиотека и локальный CLI: компактные секции, ограниченный кэш, сохранение, неизменяемые снимки, независимые экземпляры миров, атомарные правки, ревизии, идемпотентность, undo/reset и диагностика. Это основа движка; игровой сервер, графический клиент, MCP, каталог Minecraft и конвертер ещё предстоят.
+Требуются Rust 1.96+, C-компилятор для SQLite и браузер с WebGL2. Java и Node для игры не нужны. Первый запуск скачает Cargo-зависимости и соберёт release; следующие используют готовый бинарный файл.
```bash
-cd /home/emil/Desktop/shacraft-core
-bash scripts/verify.sh
-cargo run -p shacraft-tools -- --data data/demo demo
+bash scripts/run.sh --data data --listen 127.0.0.1:4000
```
-Для повторной демонстрации укажите новый пустой каталог. [Команды, измерение и JSON-lines API](docs/DEVELOPMENT.md). JSON-lines API предназначен для локальной проверки хранилища и не является MCP.
+Открыть **http://127.0.0.1:4000**. WASD — движение, пробел — прыжок, мышь — обзор, ЛКМ/ПКМ — убрать/поставить блок, E — библиотека, T — чат, F3 — диагностика. Если браузер не даёт захват мыши, обзор работает перетаскиванием. Мир выбирается слева сверху. Для Spleef два игрока входят в одну арену и запускают матч через меню. Лобби, галерея всех типов блоков и две арены создаются автоматически.
-Код доступен под MIT или Apache-2.0 на выбор. Оригинальные ресурсы Minecraft в проект не входят.
+Вся работа хранится в `--data`. Для остановки используйте Ctrl+C. Подтверждённые правки сохраняются до ответа сервера; проверены аварийные остановки и восстановление.
-## Контекст и порядок чтения
+## Что входит
-1. [Требования](docs/REQUIREMENTS.md) — что запросил пользователь.
-2. [План](docs/PLAN.md) — этапы и условия перехода.
-3. [Решения](docs/DECISIONS.md) — принятые решения и их границы.
-4. [Интерфейсы](docs/CONTRACT.md) — рабочие контракты компонентов.
-5. [Текущее состояние](docs/STATUS.md) — проверенные результаты и ближайшая работа.
-6. [Память и хранение](docs/MEMORY_AND_STORAGE.md), [совместимость](docs/COMPATIBILITY.md), [приёмка](docs/ACCEPTANCE.md).
+- `shacraft-core`: компактные секции 16³, общий ограниченный кэш, SQLite/WAL/FULL, неизменяемые снимки, независимые изменения миров, ревизии, идемпотентные правки, undo/reset. Без графики и сети.
+- `shacraft-server`: авторитетные движение, формы столкновений и взаимодействия, WebSocket/HTTP, сохраняемые сущности и настройки, законченный цикл Spleef.
+- `client/`: собственный рендерер, библиотека материалов, игроки и сущности, чат, переключение миров, переподключение, загрузка и проверка пакетов.
+- `shacraft-mcp`: настоящий отдельный stdio MCP с 17 инструментами, ресурсами и prompt; строительство по плану, чтение, отмена, сущности, арены, метрики и PNG результата.
+- `shacraft-content`: **1 196 блоков, 32 366 состояний, 158 типов сущностей Java 26.2**, DataVersion 4903. Официальные идентификаторы и измеренные коллизии, авторские модели. Плюс блок trampoline.
+- `shacraft-compat`: Anvil и Sponge `.schem` v3 в обе стороны, типизированный NBT, сохранение оригинала, отчёты о преобразованиях и перенос правок сущностей.
+- `packages/`: собственные текстуры, звук, шейдер и исполняемый WASM-модуль прыжка с ограничениями памяти и fuel.
-Работа ведётся локально в `/home/emil/Desktop/shacraft-core`. Продакшен-серверы и существующие проекты лаунчера не изменяются автоматически.
+Ванильные AI, redstone, инвентари и симуляция жидкостей не входят в этот MVP. Контекстные формы отмечены отдельно. Конвертер после правок использует явный `best-effort`; неизменённый оригинал может вернуть побайтно. Это не обещание полной совместимости с механиками Minecraft или модами NeoForge. Оригинальные ресурсы и код Minecraft не распространяются.
+
+## MCP и конвертация
+
+```bash
+cargo build --release --locked --workspace
+# В конфигурации MCP-клиента запускается этот процесс:
+target/release/shacraft-mcp --server http://127.0.0.1:4000 --data data
+
+# Импорт в новый каталог, затем запуск сервера с --data data/imported:
+target/release/shacraft-compat import-anvil /path/to/java-world data/imported
+# Экспорт выполняется при остановленном сервере, в отсутствующий каталог:
+target/release/shacraft-compat export-anvil data/imported artifacts/java-export --mode best-effort
+```
+
+Токен Control API создаётся в `data/control.token` (0600 на Unix) и не передаётся браузеру. [MCP](docs/MCP.md), [форматы и команды конвертера](docs/interop.md), [сервер и протокол](docs/SERVER.md), [расширение пакетами](docs/PACKAGES.md).
+
+## Проверить
+
+```bash
+# Rust, Clippy, аварийное восстановление, JS и реальные HTTP/WebSocket-клиенты:
+bash scripts/verify.sh
+# Release-стенд с заранее заданными бюджетами:
+cargo build --release --workspace --locked
+node scripts/benchmark_server.mjs --output artifacts/server-benchmark.json
+```
+
+Node 22+ нужен для сетевых/JS-проверок, Python 3 — для проверки аварийного хранения. Настоящий MCP SDK и независимая Java/NBT-проверка описаны в [VERIFICATION](docs/VERIFICATION.md). Проверенные числа и ограничения находятся там; сравнение с Paper не заявляется без сопоставимого стенда.
+
+## Контекст
+
+[Требования](docs/REQUIREMENTS.md) · [исходный план](docs/PLAN.md) · [решения](docs/DECISIONS.md) · [контракты ядра](docs/CONTRACT.md) · [состояние и ограничения](docs/STATUS.md) · [контент](docs/CONTENT.md).
+
+Проект заново реализован локально после зависания прежней среды. План сохранён до кода отдельным коммитом. Лицензия кода и собственных ресурсов — **MIT или Apache-2.0** на выбор. Существующий лаунчер и продакшен-серверы не изменяются этой поставкой.
diff --git a/client/app.js b/client/app.js
new file mode 100644
index 0000000..cc9ffde
--- /dev/null
+++ b/client/app.js
@@ -0,0 +1,1151 @@
+import { Renderer } from "./renderer.js";
+import {
+ clamp,
+ key,
+ direction,
+ normalizeBoxes,
+ raycast,
+ unitBox,
+} from "./math.js";
+const $ = (id) => document.getElementById(id),
+ canvas = $("world-canvas");
+const state = {
+ ws: null,
+ id: null,
+ world: "lobby",
+ revision: null,
+ blocks: new Map(),
+ materials: new Map(),
+ registry: [],
+ players: new Map(),
+ entities: [],
+ entityDefs: new Map(),
+ position: [0, 2, 8],
+ target: [0, 2, 8],
+ yaw: 0,
+ pitch: -0.14,
+ connected: false,
+ manifest: null,
+ retry: 0,
+ seq: 0,
+ tick: 0,
+ ack: 0,
+ ping: 0,
+ frames: 0,
+ fps: 0,
+ hotbar: [],
+ slot: 0,
+ selection: null,
+ packFiles: 0,
+ packBytes: 0,
+ packCached: 0,
+ resync: false,
+ viewCenter: null,
+ metrics: null,
+ name:
+ localStorage.getItem("shacraft:name") ||
+ `Игрок_${Math.floor(100 + Math.random() * 900)}`,
+};
+let audioContext = null,
+ bounceSound = null,
+ bounceBuffer = null,
+ bounceDecode = null;
+const packageStyles = new Map();
+function unlockAudio() {
+ try {
+ audioContext ??= new AudioContext();
+ audioContext.resume().catch(() => {});
+ if (bounceSound && !bounceDecode)
+ bounceDecode = audioContext
+ .decodeAudioData(bounceSound.slice(0))
+ .then((buffer) => (bounceBuffer = buffer))
+ .catch(() => {});
+ } catch {}
+}
+function bounceFeedback() {
+ renderer.bounceAt = performance.now();
+ if (audioContext && bounceBuffer) {
+ const source = audioContext.createBufferSource(),
+ gain = audioContext.createGain();
+ source.buffer = bounceBuffer;
+ gain.gain.value = 0.16;
+ source.connect(gain).connect(audioContext.destination);
+ source.start();
+ }
+}
+let pointerFallback = false;
+let renderer,
+ wsGeneration = 0,
+ retryTimer,
+ toastTimer,
+ catalogTimer,
+ lastFrame = performance.now(),
+ fpsTime = lastFrame,
+ catalogOffset = 0,
+ catalogTotal = 0,
+ catalogQuery = "",
+ catalogGeneration = 0,
+ catalogBusy = false,
+ held = new Set(),
+ drag = null,
+ lastAction = 0,
+ lastInput = 0,
+ lastSelection = "",
+ packageCache = null;
+const niceName = (s) =>
+ (s || "")
+ .replace(/^minecraft:/, "")
+ .replace(/\[.*$/, "")
+ .replaceAll("_", " ");
+const baseName = (s) => (s || "").replace(/\[.*$/, "");
+const bytes = (n) =>
+ Number.isFinite(n)
+ ? n >= 1048576
+ ? `${(n / 1048576).toFixed(1)} МиБ`
+ : n >= 1024
+ ? `${(n / 1024).toFixed(1)} КиБ`
+ : `${n} Б`
+ : "—";
+const isUIOpen = () =>
+ ["menu", "worlds", "library", "diagnostics"].some((id) => !$(id).hidden) ||
+ !$("chat-form").hidden;
+function toast(message, error = false, duration = 4300) {
+ $("toast").textContent = message;
+ $("toast").classList.toggle("error", error);
+ $("toast").hidden = false;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => ($("toast").hidden = true), duration);
+}
+function connection(label, bad = false) {
+ $("connection").textContent = label;
+ $("connection").classList.toggle("bad", bad);
+}
+function chat(name, text, system = false) {
+ const row = document.createElement("div");
+ row.textContent = name ? `${name}: ${text}` : text;
+ if (system) row.className = "system";
+ $("chat-log").append(row);
+ while ($("chat-log").children.length > 6) $("chat-log").firstChild.remove();
+}
+function send(message) {
+ if (state.ws?.readyState === WebSocket.OPEN) {
+ state.ws.send(JSON.stringify(message));
+ return true;
+ }
+ return false;
+}
+function controlsZero() {
+ held.clear();
+ if (state.connected)
+ send({
+ type: "input",
+ seq: ++state.seq,
+ yaw: state.yaw,
+ pitch: state.pitch,
+ forward: 0,
+ strafe: 0,
+ jump: false,
+ });
+}
+function closePanels() {
+ for (const id of ["menu", "worlds", "library", "diagnostics"])
+ $(id).hidden = true;
+}
+function panel(id) {
+ const wasOpen = !$(id).hidden;
+ closePanels();
+ document.exitPointerLock?.();
+ controlsZero();
+ $(id).hidden = wasOpen;
+ if (!wasOpen && id === "library") {
+ loadCatalog(true);
+ $("catalog-search").focus();
+ }
+ if (!wasOpen && id === "worlds") loadWorlds();
+ if (!wasOpen && id === "diagnostics") fetchMetrics();
+}
+function release() {
+ document.exitPointerLock?.();
+ controlsZero();
+}
+function acquire() {
+ unlockAudio();
+ closePanels();
+ $("chat-form").hidden = true;
+ if (!state.connected) {
+ toast("Соединение с сервером ещё не установлено.");
+ return;
+ }
+ try {
+ const p = canvas.requestPointerLock?.();
+ p?.catch?.(() => {
+ pointerFallback = true;
+ toast(
+ "Захват мыши недоступен: перетаскивайте для обзора, щёлкните для правки.",
+ );
+ });
+ } catch {
+ pointerFallback = true;
+ toast("Для обзора удерживайте кнопку мыши и двигайте курсор.");
+ }
+ canvas.focus();
+}
+function addMaterials(items = []) {
+ let changed = false;
+ for (const item of items) {
+ if (!item || !Number.isInteger(item.id)) continue;
+ const prev = state.materials.get(item.id),
+ mat = { ...prev, ...item };
+ mat.state = mat.state || state.registry[mat.id] || "unknown";
+ mat.color = mat.color || [150, 152, 142];
+ mat.render = normalizeBoxes(
+ mat.render ||
+ mat.render_boxes ||
+ mat.boxes ||
+ (mat.id === 0 ? [] : [unitBox]),
+ );
+ mat.collision = normalizeBoxes(
+ mat.collision || mat.boxes || (mat.solid === false ? [] : [unitBox]),
+ );
+ mat.transparent =
+ mat.opacity < 1 || /glass|water|leaves|ice/.test(mat.state);
+ const style =
+ packageStyles.get(mat.state) || packageStyles.get(baseName(mat.state));
+ if (style?.color) mat.color = style.color;
+ mat.effect = style?.effect;
+ state.materials.set(mat.id, mat);
+ if (!prev) changed = true;
+ }
+ return changed;
+}
+function snapshot(m) {
+ state.id = m.id ?? state.id;
+ state.world = m.world || state.world;
+ state.registry = m.registry || state.registry;
+ addMaterials(m.materials);
+ state.blocks = new Map();
+ for (const b of m.blocks || [])
+ if (b.block) state.blocks.set(key(b.pos), b.block);
+ state.revision = m.revision;
+ state.resync = false;
+ state.viewCenter =
+ m.view_center || m.spawn?.map((v) => Math.floor(v / 16) * 16) || null;
+ if (m.spawn) {
+ state.position = [...m.spawn];
+ state.target = [...m.spawn];
+ }
+ if (m.players) updatePlayers(m.players, true);
+ if (m.entities) state.entities = m.entities;
+ renderer.replace(state.blocks, state.materials);
+ $("world-button").textContent = `${state.world} ▾`;
+ document.title = `Shacraft · ${state.world}`;
+ if (m.type === "welcome") {
+ state.connected = true;
+ state.retry = 0;
+ connection("В сети");
+ chat("", `Вы вошли в ${state.world}`, true);
+ }
+ seedHotbar();
+ loadMissingMaterials();
+ updateDiagnostics();
+}
+function updatePlayers(players, instant = false) {
+ const present = new Set();
+ for (const p of players) {
+ present.add(p.id);
+ if (p.id === state.id) {
+ if (p.position) {
+ const under = [
+ Math.floor(state.target[0]),
+ Math.floor(state.target[1] - 0.1),
+ Math.floor(state.target[2]),
+ ],
+ underMat = state.materials.get(state.blocks.get(key(under)));
+ if (
+ !instant &&
+ p.position[1] - state.target[1] > 0.38 &&
+ underMat?.effect === "bounce"
+ )
+ bounceFeedback();
+ state.target = p.position;
+ if (
+ instant ||
+ Math.hypot(...p.position.map((v, i) => v - state.position[i])) > 4
+ )
+ state.position = [...p.position];
+ }
+ continue;
+ }
+ const old = state.players.get(p.id);
+ state.players.set(p.id, {
+ ...p,
+ position: old && !instant ? old.position : [...p.position],
+ target: [...p.position],
+ });
+ }
+ for (const id of state.players.keys())
+ if (!present.has(id)) state.players.delete(id);
+}
+function inView(pos) {
+ const center = state.viewCenter;
+ return (
+ !center ||
+ pos.every(
+ (v, i) => v >= center[i] - (i === 1 ? 8 : 32) && v <= center[i] + 31,
+ )
+ );
+}
+function blockUpdate(m) {
+ if (state.revision === null || state.resync) return;
+ if (m.revision <= state.revision) return;
+ if (m.revision !== state.revision + 1) {
+ state.resync = true;
+ send({ type: "resync" });
+ toast("Сверяем изменения с сервером…");
+ return;
+ }
+ const changes = (m.changes || []).filter((change) => inView(change.pos));
+ const needed = new Set(changes.map((change) => change.block));
+ addMaterials((m.materials || []).filter((mat) => needed.has(mat.id)));
+ for (const change of changes) {
+ if (change.block) state.blocks.set(key(change.pos), change.block);
+ else state.blocks.delete(key(change.pos));
+ }
+ state.revision = m.revision;
+ renderer.change(changes);
+ loadMissingMaterials();
+}
+function onMessage(m) {
+ switch (m.type) {
+ case "welcome":
+ case "snapshot":
+ snapshot(m);
+ break;
+ case "state":
+ state.tick = m.tick ?? state.tick;
+ state.ack = m.ack ?? state.ack;
+ if (m.players) updatePlayers(m.players);
+ if (m.entities) state.entities = m.entities;
+ if (m.match) showMatch(m.match);
+ break;
+ case "blocks":
+ blockUpdate(m);
+ break;
+ case "chat":
+ chat(m.name, m.text);
+ break;
+ case "error":
+ toast(m.message || m.error || "Сервер отклонил действие", true);
+ if (/revision|resync/i.test(m.message || "")) send({ type: "resync" });
+ if (state.resync && /snapshot rate limited/i.test(m.message || "")) {
+ const generation = wsGeneration;
+ setTimeout(() => {
+ if (generation === wsGeneration && state.connected && state.resync)
+ send({ type: "resync" });
+ }, 600);
+ }
+ break;
+ case "pong":
+ state.ping = Math.max(
+ 0,
+ Math.round(
+ performance.now() - Number(m.client_time ?? m.time ?? m.timestamp),
+ ),
+ );
+ break;
+ case "entities":
+ state.entities = m.entities || [];
+ break;
+ case "match":
+ showMatch(m.match || m);
+ break;
+ case "notice":
+ toast(m.message);
+ break;
+ }
+}
+async function getJSON(url) {
+ const response = await fetch(url, { cache: "no-store" });
+ if (!response.ok)
+ throw Error(`${url.split("?")[0]}: HTTP ${response.status}`);
+ return response.json();
+}
+async function verifyPackages(manifest, generation) {
+ if (!crypto.subtle)
+ throw Error(
+ "Для проверки пакетов нужен безопасный контекст (localhost или HTTPS).",
+ );
+ state.packFiles = 0;
+ state.packBytes = 0;
+ state.packCached = 0;
+ packageStyles.clear();
+ try {
+ packageCache = await caches.open("shacraft-packages-v1");
+ } catch {
+ packageCache = null;
+ }
+ let total = 0;
+ for (const pack of manifest.packages || []) {
+ const resources = pack.files || pack.resources || [];
+ const verifiedFiles = new Map();
+ for (const file of resources) {
+ if (generation !== wsGeneration) return;
+ const hash = (file.sha256 || file.hash || "").toLowerCase();
+ if (!/^[a-f0-9]{64}$/.test(hash))
+ throw Error(`Некорректный SHA-256: ${pack.id}/${file.path}`);
+ const url = new URL(
+ file.url || `/packages/${pack.id}/${pack.version}/${file.path}`,
+ location.origin,
+ );
+ if (url.origin !== location.origin)
+ throw Error("Пакеты должны загружаться с игрового сервера.");
+ const size = file.size ?? file.bytes;
+ if (
+ size !== undefined &&
+ (!Number.isSafeInteger(size) || size < 0 || size > 64 * 1024 * 1024)
+ )
+ throw Error("Превышен лимит размера ресурса.");
+ const cacheURL = new URL(`/__shacraft_cache__/${hash}`, location.origin)
+ .href;
+ let response = await packageCache?.match(cacheURL);
+ const cached = !!response;
+ if (!response) {
+ response = await fetch(url);
+ if (!response.ok)
+ throw Error(
+ `Не удалось загрузить пакет: ${pack.id}/${file.path} (${response.status})`,
+ );
+ }
+ const blob = await response.blob();
+ total += blob.size;
+ if (blob.size > 64 * 1024 * 1024 || total > 128 * 1024 * 1024)
+ throw Error("Превышен лимит памяти для пакетов.");
+ if (size !== undefined && blob.size !== size)
+ throw Error(`Размер ресурса не совпадает: ${pack.id}/${file.path}`);
+ const digest = Array.from(
+ new Uint8Array(
+ await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()),
+ ),
+ )
+ .map((n) => n.toString(16).padStart(2, "0"))
+ .join("");
+ if (digest !== hash) {
+ await packageCache?.delete(cacheURL);
+ throw Error(`Хеш ресурса не совпадает: ${pack.id}/${file.path}`);
+ }
+ if (!cached && packageCache)
+ try {
+ await packageCache.put(cacheURL, new Response(blob));
+ } catch {
+ /* Quota failures do not weaken integrity verification. */
+ }
+ state.packFiles++;
+ state.packBytes += blob.size;
+ state.packCached += cached ? 1 : 0;
+ verifiedFiles.set(file.path, blob);
+ $("package-status").textContent =
+ `${state.packFiles} файлов проверено · ${bytes(state.packBytes)} · из кэша ${state.packCached}`;
+ connection(`Пакеты ${state.packFiles}`);
+ }
+ // Resolve declared references only after every resource passed integrity checks.
+ // The supported client hook is declarative `effect: bounce`, with no package JS.
+ for (const file of resources.filter(
+ (f) => f.role === "client-style" || f.path?.endsWith("style.json"),
+ )) {
+ const style = JSON.parse(await verifiedFiles.get(file.path).text());
+ if (style.schema !== 1)
+ throw Error(`Неизвестная схема стиля пакета ${pack.id}`);
+ if (typeof style.block === "string")
+ packageStyles.set(style.block, style);
+ const texture = verifiedFiles.get(style.texture);
+ if (texture)
+ await renderer.setTexture(texture, style.effect === "bounce");
+ if (style.effect === "bounce") {
+ const shader = verifiedFiles.get(style.shader);
+ if (shader) renderer.setBounceShader(await shader.text());
+ const sound = verifiedFiles.get(style.sound);
+ if (sound) {
+ bounceSound = await sound.arrayBuffer();
+ bounceBuffer = null;
+ bounceDecode = null;
+ if (audioContext) unlockAudio();
+ }
+ }
+ }
+ }
+ if (!state.packFiles)
+ $("package-status").textContent =
+ "Сервер не требует дополнительных файлов.";
+ if (packageCache) {
+ const keys = await packageCache.keys();
+ if (keys.length > 512)
+ await Promise.all(
+ keys.slice(0, keys.length - 512).map((k) => packageCache.delete(k)),
+ );
+ }
+}
+async function connect() {
+ const generation = ++wsGeneration;
+ clearTimeout(retryTimer);
+ const old = state.ws;
+ state.ws = null;
+ old?.close();
+ state.connected = false;
+ state.id = null;
+ state.players.clear();
+ state.seq = 0;
+ connection("Подключение");
+ controlsZero();
+ try {
+ const manifest = await getJSON("/api/manifest");
+ if (manifest.protocol !== 1)
+ throw Error(`Неподдерживаемый протокол ${manifest.protocol}`);
+ state.manifest = manifest;
+ await verifyPackages(manifest, generation);
+ if (generation !== wsGeneration) return;
+ const ws = new WebSocket(
+ `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`,
+ );
+ state.ws = ws;
+ ws.addEventListener("open", () => {
+ if (generation !== wsGeneration) return;
+ connection("Вход в мир");
+ send({
+ type: "join",
+ protocol: 1,
+ name: state.name,
+ world: state.world,
+ manifest_hash: manifest.hash,
+ });
+ });
+ ws.addEventListener("message", (event) => {
+ if (generation !== wsGeneration) return;
+ try {
+ onMessage(JSON.parse(event.data));
+ } catch (error) {
+ console.error("Shacraft protocol error", error);
+ toast(`Ошибка данных сервера: ${error.message}`, true);
+ }
+ });
+ ws.addEventListener("close", (event) => {
+ if (generation !== wsGeneration) return;
+ state.connected = false;
+ controlsZero();
+ scheduleReconnect(event.reason || "Соединение потеряно");
+ });
+ ws.addEventListener("error", () => connection("Ошибка соединения", true));
+ await Promise.allSettled([loadWorlds(), loadEntities()]);
+ } catch (error) {
+ if (generation !== wsGeneration) return;
+ console.error(error);
+ scheduleReconnect(error.message);
+ }
+}
+function scheduleReconnect(reason) {
+ state.connected = false;
+ const seconds = Math.min(20, 2 ** state.retry++);
+ connection(`Повтор через ${seconds} с`, true);
+ toast(
+ `${reason}. Повторное подключение через ${seconds} с.`,
+ true,
+ Math.min(seconds * 1000, 6000),
+ );
+ clearTimeout(retryTimer);
+ retryTimer = setTimeout(connect, seconds * 1000);
+}
+async function loadEntities() {
+ try {
+ const result = await getJSON("/api/entities");
+ for (const e of result.items || result.entities || result)
+ state.entityDefs.set(e.name || e.kind, {
+ ...e,
+ render: normalizeBoxes(e.render),
+ });
+ } catch (error) {
+ console.warn("Entity catalog", error);
+ }
+}
+async function loadWorlds() {
+ try {
+ const data = await getJSON("/api/worlds"),
+ worlds = data.worlds || data;
+ $("world-list").replaceChildren();
+ for (const world of worlds) {
+ const b = document.createElement("button");
+ b.className =
+ "world-row" + (world.name === state.world ? " current" : "");
+ const title = document.createElement("strong");
+ title.textContent = world.name;
+ const subtitle = document.createElement("small");
+ subtitle.textContent = `Ревизия ${world.revision}${world.template ? ` · экземпляр ${world.template}` : ""}`;
+ b.append(title, subtitle);
+ b.onclick = () => {
+ if (!state.connected) {
+ toast("Дождитесь подключения к серверу.");
+ return;
+ }
+ send({ type: "switch_world", world: world.name });
+ closePanels();
+ controlsZero();
+ };
+ $("world-list").append(b);
+ }
+ } catch (error) {
+ $("world-list").textContent = error.message;
+ }
+}
+let missingPending = false;
+async function loadMissingMaterials() {
+ if (missingPending) return;
+ missingPending = true;
+ try {
+ while (state.connected) {
+ const missing = [...new Set(state.blocks.values())]
+ .filter((id) => !state.materials.has(id))
+ .slice(0, 128);
+ if (!missing.length) break;
+ const data = await getJSON(
+ `/api/catalog?ids=${missing.join(",")}&limit=128`,
+ );
+ const items = data.items || data;
+ if (!addMaterials(items)) break;
+ const loaded = new Set(items.map((material) => material.id));
+ renderer.change(
+ [...state.blocks]
+ .filter(([, id]) => loaded.has(id))
+ .map(([position, block]) => ({
+ pos: position.split(",").map(Number),
+ block,
+ })),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ seedHotbar();
+ } catch (error) {
+ console.warn("Material catalog", error);
+ } finally {
+ missingPending = false;
+ }
+}
+async function seedHotbar() {
+ if (state.hotbar.length) return;
+ const states = [
+ "minecraft:grass_block",
+ "minecraft:stone",
+ "minecraft:oak_planks",
+ "minecraft:bricks",
+ "minecraft:glass",
+ "minecraft:oak_log",
+ "minecraft:oak_leaves",
+ "minecraft:snow_block",
+ "shacraft:trampoline",
+ ];
+ let found = states.map((s) =>
+ [...state.materials.values()].find((m) => baseName(m.state) === s),
+ );
+ if (found.filter(Boolean).length < 5) {
+ try {
+ const result = await getJSON(
+ "/api/catalog?states=" + encodeURIComponent(states.join(",")),
+ );
+ addMaterials(result.items || result);
+ found = states.map((s) =>
+ [...state.materials.values()].find((m) => baseName(m.state) === s),
+ );
+ } catch {}
+ }
+ const fallback = [...state.materials.values()].filter((m) => m.id !== 0);
+ state.hotbar = found.map(
+ (m, i) => m?.id ?? fallback[i % Math.max(1, fallback.length)]?.id ?? 0,
+ );
+ renderHotbar();
+}
+function swatch(mat) {
+ const s = document.createElement("span");
+ s.className = "swatch";
+ s.style.setProperty(
+ "--block-color",
+ `rgb(${(mat?.color || [150, 152, 142]).join(",")})`,
+ );
+ s.setAttribute("aria-hidden", "true");
+ return s;
+}
+function renderHotbar() {
+ $("hotbar").replaceChildren();
+ for (let i = 0; i < 9; i++) {
+ const mat = state.materials.get(state.hotbar[i]);
+ const b = document.createElement("button");
+ b.className = `slot${i === state.slot ? " active" : ""}`;
+ b.title = `${i + 1}: ${mat?.state || "Пусто"}`;
+ b.setAttribute("aria-label", b.title);
+ b.setAttribute("aria-pressed", i === state.slot ? "true" : "false");
+ const n = document.createElement("small");
+ n.textContent = String(i + 1);
+ b.append(n, swatch(mat));
+ b.onclick = () => {
+ state.slot = i;
+ renderHotbar();
+ };
+ $("hotbar").append(b);
+ }
+ $("selected-name").textContent =
+ niceName(state.materials.get(state.hotbar[state.slot])?.state) ||
+ "Выберите блок в библиотеке";
+}
+async function loadCatalog(reset = false) {
+ if (reset) {
+ catalogGeneration++;
+ catalogOffset = 0;
+ catalogQuery = $("catalog-search").value.trim();
+ $("catalog-list").replaceChildren();
+ catalogBusy = false;
+ }
+ if (catalogBusy) return;
+ catalogBusy = true;
+ const generation = catalogGeneration;
+ $("catalog-more").disabled = true;
+ try {
+ const data = await getJSON(
+ `/api/catalog?query=${encodeURIComponent(catalogQuery)}&offset=${catalogOffset}&limit=60`,
+ );
+ if (generation !== catalogGeneration) return;
+ const items = data.items || data;
+ catalogTotal = data.total ?? items.length;
+ addMaterials(items);
+ catalogOffset += items.length;
+ $("catalog-count").textContent =
+ `${catalogTotal.toLocaleString("ru-RU")} состояний · Minecraft Java 26.2`;
+ for (const mat of items) {
+ const button = document.createElement("button");
+ button.className = "catalog-item";
+ button.title = mat.state;
+ const label = document.createElement("span");
+ label.textContent = niceName(mat.state);
+ const properties = document.createElement("small");
+ properties.textContent =
+ mat.state.match(/\[(.*)\]/)?.[1] || baseName(mat.state).split(":")[0];
+ label.append(properties);
+ button.append(swatch(mat), label);
+ button.onclick = () => {
+ state.hotbar[state.slot] = mat.id;
+ renderHotbar();
+ closePanels();
+ toast(`${niceName(mat.state)} · ячейка ${state.slot + 1}`, false, 1800);
+ };
+ $("catalog-list").append(button);
+ }
+ if (!catalogOffset) {
+ const empty = document.createElement("p");
+ empty.className = "catalog-empty";
+ empty.textContent =
+ "Ничего не найдено. Используйте английское имя блока.";
+ $("catalog-list").append(empty);
+ }
+ $("catalog-more").hidden = catalogOffset >= catalogTotal;
+ } catch (error) {
+ if (generation === catalogGeneration) {
+ toast(error.message, true);
+ $("catalog-count").textContent = "Не удалось загрузить каталог";
+ }
+ } finally {
+ if (generation === catalogGeneration) {
+ catalogBusy = false;
+ $("catalog-more").disabled = false;
+ }
+ }
+}
+function showMatch(m) {
+ if (!m || m.phase === "idle" || (m.phase === "waiting" && !m.remaining)) {
+ $("match").hidden = true;
+ return;
+ }
+ $("match").hidden = false;
+ const phases = {
+ countdown: "До начала",
+ active: "Spleef",
+ running: "Spleef",
+ finished: "Матч завершён",
+ waiting: "Ожидаем игроков",
+ };
+ const winner = m.winner ? ` · Победитель: ${m.winner}` : "";
+ $("match").textContent =
+ `${phases[m.phase] || m.phase}${m.remaining !== undefined ? ` · ${Math.ceil(m.remaining)} с` : ""}${winner}`;
+}
+function action(kind) {
+ if (!state.connected || isUIOpen()) return;
+ const hit = state.selection;
+ if (!hit) return;
+ const now = performance.now();
+ if (now - lastAction < 130) return;
+ lastAction = now;
+ send({
+ type: "input",
+ seq: ++state.seq,
+ yaw: state.yaw,
+ pitch: state.pitch,
+ forward: (held.has("KeyW") ? 1 : 0) - (held.has("KeyS") ? 1 : 0),
+ strafe: (held.has("KeyD") ? 1 : 0) - (held.has("KeyA") ? 1 : 0),
+ jump: held.has("Space"),
+ });
+ if (kind === "break") send({ type: "break", pos: hit.pos });
+ else if (kind === "place") {
+ const mat = state.materials.get(state.hotbar[state.slot]);
+ if (!mat?.id) return;
+ send({
+ type: "place",
+ pos: hit.pos.map((v, i) => v + hit.normal[i]),
+ block: mat.id,
+ state: mat.state,
+ });
+ } else if (kind === "pick") {
+ state.hotbar[state.slot] = hit.block;
+ renderHotbar();
+ }
+}
+function openChat() {
+ release();
+ closePanels();
+ $("chat-form").hidden = false;
+ $("chat-input").focus();
+}
+$("library-button").onclick = () => panel("library");
+$("diagnostics-button").onclick = () => panel("diagnostics");
+$("menu-button").onclick = () => panel("menu");
+$("world-button").onclick = () => panel("worlds");
+for (const b of document.querySelectorAll("[data-close]"))
+ b.onclick = () => ($(b.dataset.close).hidden = true);
+$("resume-button").onclick = acquire;
+$("player-name").value = state.name;
+$("rename-button").onclick = () => {
+ const name = $("player-name").value.trim();
+ if (!name) return;
+ state.name = name;
+ localStorage.setItem("shacraft:name", name);
+ closePanels();
+ connect();
+};
+$("reconnect-button").onclick = () => {
+ closePanels();
+ connect();
+};
+$("resync-button").onclick = () => {
+ if (send({ type: "resync" })) toast("Запрошен актуальный снимок мира.");
+};
+$("respawn-button").onclick = () => {
+ send({ type: "respawn" });
+ closePanels();
+};
+$("spleef-button").onclick = () => {
+ send({ type: "start_match" });
+ closePanels();
+};
+$("catalog-search").oninput = () => {
+ clearTimeout(catalogTimer);
+ catalogTimer = setTimeout(() => loadCatalog(true), 220);
+};
+$("catalog-more").onclick = () => loadCatalog();
+$("chat-form").onsubmit = (e) => {
+ e.preventDefault();
+ const text = $("chat-input").value.trim();
+ if (text && state.connected) send({ type: "chat", text });
+ $("chat-input").value = "";
+ $("chat-form").hidden = true;
+ canvas.focus();
+};
+window.addEventListener("keydown", (e) => {
+ const editing = /INPUT|TEXTAREA/.test(document.activeElement?.tagName);
+ if (e.code === "Escape") {
+ if (!$("chat-form").hidden) {
+ $("chat-form").hidden = true;
+ $("chat-input").blur();
+ return;
+ }
+ if (isUIOpen()) closePanels();
+ else if (document.pointerLockElement !== canvas) panel("menu");
+ controlsZero();
+ return;
+ }
+ if (editing) return;
+ if (
+ [
+ "Space",
+ "KeyW",
+ "KeyA",
+ "KeyS",
+ "KeyD",
+ "ArrowUp",
+ "ArrowDown",
+ "ArrowLeft",
+ "ArrowRight",
+ "Tab",
+ "F3",
+ ].includes(e.code)
+ )
+ e.preventDefault();
+ if (e.repeat) return;
+ if (e.code === "KeyE") {
+ panel("library");
+ return;
+ }
+ if (e.code === "F3") {
+ panel("diagnostics");
+ return;
+ }
+ if (e.code === "KeyT" || e.code === "Enter") {
+ openChat();
+ return;
+ }
+ if (/^Digit[1-9]$/.test(e.code)) {
+ state.slot = Number(e.code.slice(-1)) - 1;
+ renderHotbar();
+ return;
+ }
+ if (!isUIOpen()) held.add(e.code);
+});
+window.addEventListener("keyup", (e) => held.delete(e.code));
+window.addEventListener("blur", () => {
+ drag = null;
+ controlsZero();
+});
+document.addEventListener("visibilitychange", () => {
+ if (document.hidden) controlsZero();
+});
+document.addEventListener("pointerlockchange", () => {
+ const locked = document.pointerLockElement === canvas;
+ document.body.classList.toggle("locked", locked);
+ if (!locked) controlsZero();
+});
+function look(dx, dy) {
+ const sensitivity = Number($("sensitivity").value);
+ state.yaw += clamp(dx, -160, 160) * sensitivity;
+ state.pitch = clamp(
+ state.pitch - clamp(dy, -160, 160) * sensitivity,
+ -1.48,
+ 1.48,
+ );
+}
+document.addEventListener("mousemove", (e) => {
+ if (document.pointerLockElement === canvas) look(e.movementX, e.movementY);
+});
+canvas.addEventListener("contextmenu", (e) => e.preventDefault());
+canvas.addEventListener("pointerdown", (e) => {
+ unlockAudio();
+ if (isUIOpen()) {
+ closePanels();
+ return;
+ }
+ if (document.pointerLockElement === canvas) {
+ action(e.button === 2 ? "place" : e.button === 1 ? "pick" : "break");
+ return;
+ }
+ drag = {
+ x: e.clientX,
+ y: e.clientY,
+ startX: e.clientX,
+ startY: e.clientY,
+ button: e.button,
+ moved: false,
+ };
+ canvas.setPointerCapture(e.pointerId);
+});
+canvas.addEventListener("pointermove", (e) => {
+ if (!drag || document.pointerLockElement === canvas) return;
+ const dx = e.clientX - drag.x,
+ dy = e.clientY - drag.y;
+ if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 3)
+ drag.moved = true;
+ if (drag.moved) {
+ pointerFallback = true;
+ look(dx, dy);
+ }
+ drag.x = e.clientX;
+ drag.y = e.clientY;
+});
+canvas.addEventListener("pointerup", (e) => {
+ if (!drag) return;
+ const d = drag;
+ drag = null;
+ if (!d.moved) {
+ if (d.button === 2) action("place");
+ else if (d.button === 1) action("pick");
+ else if (pointerFallback) action("break");
+ else acquire();
+ }
+});
+canvas.addEventListener("pointercancel", () => (drag = null));
+canvas.addEventListener(
+ "wheel",
+ (e) => {
+ e.preventDefault();
+ state.slot = (state.slot + (e.deltaY > 0 ? 1 : 8)) % 9;
+ renderHotbar();
+ },
+ { passive: false },
+);
+async function fetchMetrics() {
+ try {
+ state.metrics = await getJSON("/api/metrics");
+ } catch {}
+ updateDiagnostics();
+}
+function updateDiagnostics() {
+ const m = state.metrics || {},
+ storage = m.storage || m.store || m,
+ mem = m.memory || {},
+ rss = m.rss_bytes ?? m.process_rss_bytes ?? mem.rss_bytes;
+ const rows = [
+ ["Соединение", state.connected ? "В сети" : "Нет соединения"],
+ ["Мир", state.world],
+ ["Ревизия", state.revision ?? "—"],
+ ["Видимые блоки", state.blocks.size.toLocaleString("ru-RU")],
+ ["Сущности", String(state.entities.length)],
+ ["Игроки", String(state.players.size + (state.id ? 1 : 0))],
+ ["WebGL", renderer?.lost ? "Контекст потерян" : "WebGL2"],
+ ["Кадры / с", String(state.fps)],
+ ["Треугольники", renderer?.triangles.toLocaleString("ru-RU") || "0"],
+ ["Ожидают построения", String(renderer?.dirty.size || 0)],
+ ["Тик сервера", String(state.tick)],
+ ["Подтверждён ввод", String(state.ack)],
+ ["Ping", state.ping ? `${state.ping} мс` : "—"],
+ ["Координаты", state.position.map((n) => n.toFixed(1)).join(", ")],
+ ["RAM процесса", bytes(rss)],
+ [
+ "Данные кэша",
+ bytes(storage.cache_payload_bytes ?? storage.cache?.payload_bytes),
+ ],
+ ["Пакеты", `${state.packFiles} файлов`],
+ ];
+ $("debug-values").replaceChildren();
+ for (const [label, value] of rows) {
+ const dt = document.createElement("dt");
+ dt.textContent = label;
+ const dd = document.createElement("dd");
+ dd.textContent = value;
+ dd.dataset.metric = label;
+ $("debug-values").append(dt, dd);
+ }
+ canvas.dataset.world = state.world;
+ canvas.dataset.revision = String(state.revision ?? "");
+ canvas.dataset.blocks = String(state.blocks.size);
+ canvas.dataset.entities = String(state.entities.length);
+ canvas.dataset.webgl = renderer?.lost ? "lost" : "ready";
+ canvas.dataset.connected = String(state.connected);
+}
+const playerLabels = new Map();
+function updateLabels() {
+ for (const [id, label] of playerLabels)
+ if (!state.players.has(id)) {
+ label.remove();
+ playerLabels.delete(id);
+ }
+ for (const [id, p] of state.players) {
+ let label = playerLabels.get(id);
+ if (!label) {
+ label = document.createElement("div");
+ label.className = "player-label";
+ $("labels").append(label);
+ playerLabels.set(id, label);
+ }
+ label.textContent = p.name;
+ const at = renderer.project([
+ p.position[0],
+ p.position[1] + 1.95,
+ p.position[2],
+ ]);
+ label.hidden =
+ !at ||
+ at[0] < 0 ||
+ at[0] > canvas.clientWidth ||
+ at[1] < 0 ||
+ at[1] > canvas.clientHeight;
+ if (at) {
+ label.style.left = `${at[0]}px`;
+ label.style.top = `${at[1]}px`;
+ }
+ }
+}
+function frame(time) {
+ const dt = Math.min(0.05, (time - lastFrame) / 1000);
+ lastFrame = time;
+ state.frames++;
+ const blend = 1 - Math.exp(-dt * 19);
+ for (let i = 0; i < 3; i++)
+ state.position[i] += (state.target[i] - state.position[i]) * blend;
+ for (const p of state.players.values())
+ for (let i = 0; i < 3; i++)
+ p.position[i] += (p.target[i] - p.position[i]) * blend;
+ const camera = {
+ eye: [state.position[0], state.position[1] + 1.62, state.position[2]],
+ yaw: state.yaw,
+ pitch: state.pitch,
+ };
+ state.selection = raycast(
+ camera.eye,
+ direction(state.yaw, state.pitch),
+ state.blocks,
+ state.materials,
+ 6,
+ );
+ const selectionKey = state.selection
+ ? `${key(state.selection.pos)}:${state.selection.block}`
+ : "";
+ if (selectionKey !== lastSelection) {
+ lastSelection = selectionKey;
+ renderer.updateSelection(state.selection);
+ $("target-label").textContent = state.selection
+ ? niceName(
+ state.materials.get(state.selection.block)?.state ||
+ state.registry[state.selection.block],
+ )
+ : "";
+ }
+ renderer.draw(
+ camera,
+ [...state.players.values()],
+ state.entities,
+ state.entityDefs,
+ );
+ updateLabels();
+ if (state.connected && time - lastInput >= 50) {
+ lastInput = time;
+ let forward = isUIOpen()
+ ? 0
+ : (held.has("KeyW") || held.has("ArrowUp") ? 1 : 0) -
+ (held.has("KeyS") || held.has("ArrowDown") ? 1 : 0),
+ strafe = isUIOpen()
+ ? 0
+ : (held.has("KeyD") || held.has("ArrowRight") ? 1 : 0) -
+ (held.has("KeyA") || held.has("ArrowLeft") ? 1 : 0);
+ send({
+ type: "input",
+ seq: ++state.seq,
+ yaw: state.yaw,
+ pitch: state.pitch,
+ forward,
+ strafe,
+ jump: !isUIOpen() && held.has("Space"),
+ });
+ }
+ if (time - fpsTime > 1000) {
+ state.fps = Math.round((state.frames * 1000) / (time - fpsTime));
+ state.frames = 0;
+ fpsTime = time;
+ updateDiagnostics();
+ }
+ requestAnimationFrame(frame);
+}
+try {
+ renderer = new Renderer(canvas);
+ renderHotbar();
+ connect();
+ requestAnimationFrame(frame);
+ setInterval(() => {
+ if (state.connected) send({ type: "ping", client_time: performance.now() });
+ if (!$("diagnostics").hidden) fetchMetrics();
+ }, 3000);
+} catch (error) {
+ console.error(error);
+ $("fatal").textContent = error.message;
+ $("fatal").hidden = false;
+ connection("Ошибка графики", true);
+}
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..e4c39a9
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+ Shacraft · Мир
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ W A S D движениеПробел прыжокЛКМ / ПКМ убрать / поставитьT чат
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/math.js b/client/math.js
new file mode 100644
index 0000000..26045c8
--- /dev/null
+++ b/client/math.js
@@ -0,0 +1,172 @@
+export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+export const key = (p) => `${p[0]},${p[1]},${p[2]}`;
+export const direction = (yaw, pitch) => [
+ Math.sin(yaw) * Math.cos(pitch),
+ Math.sin(pitch),
+ -Math.cos(yaw) * Math.cos(pitch),
+];
+export function multiply(a, b) {
+ const o = new Float32Array(16);
+ for (let c = 0; c < 4; c++)
+ for (let r = 0; r < 4; r++)
+ for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
+ return o;
+}
+export function perspective(fovy, aspect, near, far) {
+ const f = 1 / Math.tan(fovy / 2),
+ nf = 1 / (near - far);
+ return new Float32Array([
+ f / aspect,
+ 0,
+ 0,
+ 0,
+ 0,
+ f,
+ 0,
+ 0,
+ 0,
+ 0,
+ (far + near) * nf,
+ -1,
+ 0,
+ 0,
+ 2 * far * near * nf,
+ 0,
+ ]);
+}
+export function viewMatrix(eye, yaw, pitch) {
+ const d = direction(yaw, pitch),
+ z = d.map((v) => -v),
+ x = [Math.cos(yaw), 0, Math.sin(yaw)],
+ y = [
+ -Math.sin(yaw) * Math.sin(pitch),
+ Math.cos(pitch),
+ Math.cos(yaw) * Math.sin(pitch),
+ ];
+ return new Float32Array([
+ x[0],
+ y[0],
+ z[0],
+ 0,
+ x[1],
+ y[1],
+ z[1],
+ 0,
+ x[2],
+ y[2],
+ z[2],
+ 0,
+ -dot(x, eye),
+ -dot(y, eye),
+ -dot(z, eye),
+ 1,
+ ]);
+}
+export function dot(a, b) {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+export function project(p, m, w, h) {
+ const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
+ y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
+ z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
+ q = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15];
+ return q > 0 && z / q < 1
+ ? [((x / q + 1) * w) / 2, ((1 - y / q) * h) / 2]
+ : null;
+}
+export const unitBox = { min: [0, 0, 0], max: [1, 1, 1] };
+export function normalizeBoxes(boxes) {
+ return (boxes || [])
+ .map((b) =>
+ Array.isArray(b) ? { min: b.slice(0, 3), max: b.slice(3, 6) } : b,
+ )
+ .filter((b) => b.min?.length === 3 && b.max?.length === 3);
+}
+export function rayBox(origin, dir, min, max, reach) {
+ let tmin = -Infinity,
+ tmax = Infinity,
+ normal = [0, 0, 0];
+ for (let i = 0; i < 3; i++) {
+ if (Math.abs(dir[i]) < 1e-9) {
+ if (origin[i] < min[i] || origin[i] > max[i]) return null;
+ continue;
+ }
+ let a = (min[i] - origin[i]) / dir[i],
+ b = (max[i] - origin[i]) / dir[i],
+ sign = -Math.sign(dir[i]);
+ if (a > b) [a, b] = [b, a];
+ if (a > tmin) {
+ tmin = a;
+ normal = [0, 0, 0];
+ normal[i] = sign;
+ }
+ tmax = Math.min(tmax, b);
+ if (tmin > tmax) return null;
+ }
+ if (tmax < 0 || tmin > reach) return null;
+ return { distance: Math.max(0, tmin), normal };
+}
+// A bounded DDA finds candidate cells; exact material boxes determine selection and face.
+export function raycast(origin, dir, blocks, materials, reach = 6) {
+ const cell = origin.map(Math.floor),
+ step = dir.map(Math.sign);
+ const delta = dir.map((v) => (v === 0 ? Infinity : Math.abs(1 / v)));
+ const next = dir.map((v, i) =>
+ v === 0 ? Infinity : ((v > 0 ? cell[i] + 1 : cell[i]) - origin[i]) / v,
+ );
+ const visited = new Set();
+ let best = null;
+ for (let n = 0; n < 128; n++) {
+ // Official shapes can extend outside their owning cell (e.g. a fence is 1.5 high).
+ // Inspect neighboring owners once, but intersect their exact boxes in world space.
+ for (let x = -3; x <= 3; x++)
+ for (let y = -3; y <= 3; y++)
+ for (let z = -3; z <= 3; z++) {
+ const owner = [cell[0] + x, cell[1] + y, cell[2] + z],
+ idKey = key(owner);
+ if (visited.has(idKey)) continue;
+ visited.add(idKey);
+ const id = blocks.get(idKey);
+ if (!id) continue;
+ const mat = materials.get(id),
+ boxes = mat ? mat.render : [unitBox];
+ for (const box of boxes || [unitBox]) {
+ const hit = rayBox(
+ origin,
+ dir,
+ box.min.map((v, i) => v + owner[i]),
+ box.max.map((v, i) => v + owner[i]),
+ reach,
+ );
+ if (hit && (!best || hit.distance < best.distance))
+ best = { ...hit, pos: owner, block: id };
+ }
+ }
+ const axis =
+ next[0] < next[1]
+ ? next[0] < next[2]
+ ? 0
+ : 2
+ : next[1] < next[2]
+ ? 1
+ : 2;
+ if (
+ next[axis] > reach ||
+ !Number.isFinite(next[axis]) ||
+ (best && next[axis] > best.distance)
+ )
+ break;
+ cell[axis] += step[axis];
+ next[axis] += delta[axis];
+ }
+ return best;
+}
+export function isFullCube(mat) {
+ const b = mat?.render;
+ return (
+ b?.length === 1 &&
+ b[0].min.every((v) => v === 0) &&
+ b[0].max.every((v) => v === 1) &&
+ !mat.transparent
+ );
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..6b49651
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1 @@
+{"name":"shacraft-client","version":"0.1.0","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}
diff --git a/client/renderer.js b/client/renderer.js
new file mode 100644
index 0000000..f8b22a0
--- /dev/null
+++ b/client/renderer.js
@@ -0,0 +1,569 @@
+import {
+ multiply,
+ perspective,
+ viewMatrix,
+ key,
+ isFullCube,
+ unitBox,
+ project,
+} from "./math.js";
+const vertex = `#version 300 es
+precision highp float;layout(location=0) in vec3 aPos;layout(location=1) in vec3 aColor;layout(location=2) in vec3 aNormal;layout(location=3) in float aMaterial;layout(location=4) in float aOpacity;uniform mat4 uVP;out vec3 vPos;out vec3 vColor;out vec3 vNormal;flat out float vMaterial;out float vOpacity;void main(){vOpacity=aOpacity;vPos=aPos;vColor=aColor;vNormal=aNormal;vMaterial=aMaterial;gl_Position=uVP*vec4(aPos,1.);}`;
+const fragment = `#version 300 es
+precision highp float;in vec3 vPos;in vec3 vColor;in vec3 vNormal;flat in float vMaterial;in float vOpacity;uniform vec3 uEye;uniform sampler2D uTexture;uniform bool uTextured;uniform sampler2D uEffectTexture;uniform bool uEffectTextured;uniform float uPulse;out vec4 outColor;
+float hash(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}
+vec3 shacraftBounceTint(vec3 color,float pulse){return color;}
+void main(){vec3 n=normalize(vNormal);vec2 uv=abs(n.y)>.5?vPos.xz:(abs(n.x)>.5?vPos.zy:vPos.xy);vec3 p=floor((vPos+n*.002)*16.);float noise=hash(p)*.14-.07;float pattern=1.;
+if(vMaterial>1.5&&vMaterial<2.5){pattern=.89+.11*step(.07,fract(uv.y*4.));pattern*=.9+.1*step(.055,fract(uv.x*.5+floor(uv.y*4.)*.5));}
+if(vMaterial>2.5&&vMaterial<3.5){pattern=.87+.13*step(.06,fract(uv.y*4.));pattern*=.84+.16*step(.045,fract(uv.x*2.+floor(uv.y*4.)*.5));}
+if(vMaterial>3.5&&vMaterial<4.5){pattern=.91+.09*sin(floor(uv.x*16.)*1.73+sin(floor(uv.y*16.)*.22));}
+float tex=uTextured?mix(.84,1.12,texture(uTexture,uv).r):1.;if(vMaterial>4.5&&uEffectTextured)tex=mix(.75,1.2,texture(uEffectTexture,uv).g);float diffuse=max(0.,dot(n,normalize(vec3(-.45,.86,.3))));float light=.69+diffuse*.34;vec3 color=clamp((vColor+noise)*pattern*tex*light,0.,1.);if(vMaterial>.5&&vMaterial<1.5&&n.y<.5)color=mix(color,vec3(.38,.29,.17),.65);
+if(vMaterial>4.5)color=shacraftBounceTint(color,uPulse);float fog=1.-exp(-pow(distance(vPos,uEye)/74.,2.));color=mix(color,vec3(.70,.80,.79),fog*.91);outColor=vec4(color,vOpacity);}`;
+const skyVert = `#version 300 es
+precision highp float;out vec2 vUV;void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);vUV=p;gl_Position=vec4(p*2.-1.,1.,1.);}`;
+const skyFrag = `#version 300 es
+precision highp float;in vec2 vUV;uniform float uPitch;uniform float uYaw;uniform float uAspect;out vec4 outColor;void main(){float h=vUV.y+uPitch*.72;vec3 color=mix(vec3(.77,.83,.77),vec3(.38,.65,.78),smoothstep(.14,1.2,h));vec2 sun=vec2(.77-uYaw*.3,.82-uPitch*.72);float d=length((vUV-sun)*vec2(uAspect,1.));color=mix(color,vec3(1.,.98,.8),smoothstep(.048,.039,d)*.98);color+=vec3(.045,.025,0.)*exp(-d*7.);outColor=vec4(color,1.);}`;
+const lineVertex = `#version 300 es
+precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;void main(){gl_Position=uVP*vec4(aPos,1.);}`;
+const lineFragment = `#version 300 es
+precision highp float;out vec4 outColor;void main(){outColor=vec4(.13,.21,.12,1.);}`;
+const faces = [
+ {
+ n: [1, 0, 0],
+ v: [
+ [1, 0, 1],
+ [1, 0, 0],
+ [1, 1, 0],
+ [1, 1, 1],
+ ],
+ },
+ {
+ n: [-1, 0, 0],
+ v: [
+ [0, 0, 0],
+ [0, 0, 1],
+ [0, 1, 1],
+ [0, 1, 0],
+ ],
+ },
+ {
+ n: [0, 1, 0],
+ v: [
+ [0, 1, 1],
+ [1, 1, 1],
+ [1, 1, 0],
+ [0, 1, 0],
+ ],
+ },
+ {
+ n: [0, -1, 0],
+ v: [
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 0, 1],
+ [0, 0, 1],
+ ],
+ },
+ {
+ n: [0, 0, 1],
+ v: [
+ [0, 0, 1],
+ [1, 0, 1],
+ [1, 1, 1],
+ [0, 1, 1],
+ ],
+ },
+ {
+ n: [0, 0, -1],
+ v: [
+ [1, 0, 0],
+ [0, 0, 0],
+ [0, 1, 0],
+ [1, 1, 0],
+ ],
+ },
+];
+function program(gl, vs, fs) {
+ const p = gl.createProgram();
+ for (const [type, source] of [
+ [gl.VERTEX_SHADER, vs],
+ [gl.FRAGMENT_SHADER, fs],
+ ]) {
+ const s = gl.createShader(type);
+ gl.shaderSource(s, source);
+ gl.compileShader(s);
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
+ throw Error(gl.getShaderInfoLog(s));
+ gl.attachShader(p, s);
+ gl.deleteShader(s);
+ }
+ gl.linkProgram(p);
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS))
+ throw Error(gl.getProgramInfoLog(p));
+ return p;
+}
+function boxVertices(
+ out,
+ pos,
+ box,
+ color,
+ material = 0,
+ skip = () => false,
+ yaw = 0,
+ opacity = 1,
+) {
+ for (let f = 0; f < 6; f++) {
+ if (skip(faces[f].n)) continue;
+ const face = faces[f],
+ c = Math.cos(yaw),
+ s = Math.sin(yaw),
+ n = [
+ face.n[0] * c - face.n[2] * s,
+ face.n[1],
+ face.n[0] * s + face.n[2] * c,
+ ];
+ for (const i of [0, 1, 2, 0, 2, 3]) {
+ const v = face.v[i].map(
+ (v, k) => box.min[k] + v * (box.max[k] - box.min[k]),
+ );
+ out.push(
+ pos[0] + v[0] * c - v[2] * s,
+ pos[1] + v[1],
+ pos[2] + v[0] * s + v[2] * c,
+ ...color,
+ ...n,
+ material,
+ opacity,
+ );
+ }
+ }
+}
+function materialType(state = "", effect) {
+ if (effect === "bounce") return 5;
+ if (state.includes("grass_block")) return 1;
+ if (state.includes("planks")) return 2;
+ if (state.includes("brick")) return 3;
+ if (/log|stem|wood/.test(state)) return 4;
+ return 0;
+}
+const section = (p) => p.map((v) => Math.floor(v / 16)).join(",");
+export class Renderer {
+ constructor(canvas) {
+ this.canvas = canvas;
+ const gl = (this.gl = canvas.getContext("webgl2", {
+ antialias: true,
+ alpha: false,
+ preserveDrawingBuffer: true,
+ powerPreference: "high-performance",
+ }));
+ if (!gl)
+ throw Error(
+ "WebGL2 недоступен. Включите аппаратное ускорение или откройте клиент в браузере с поддержкой WebGL2.",
+ );
+ this.program = program(gl, vertex, fragment);
+ this.sky = program(gl, skyVert, skyFrag);
+ this.line = program(gl, lineVertex, lineFragment);
+ this.sections = new Map();
+ this.dirty = new Set();
+ this.blocks = new Map();
+ this.materials = new Map();
+ this.texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ 1,
+ 1,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array([180, 180, 180, 255]),
+ );
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ this.textured = false;
+ this.effectTexture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ 1,
+ 1,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array([180, 180, 180, 255]),
+ );
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ this.effectTextured = false;
+ this.bounceAt = -10000;
+ this.dynamic = this.mesh([]);
+ this.lines = this.mesh([], 3);
+ this.triangles = 0;
+ this.vp = new Float32Array(16);
+ this.lost = false;
+ canvas.addEventListener("webglcontextlost", (e) => {
+ e.preventDefault();
+ this.lost = true;
+ });
+ canvas.addEventListener("webglcontextrestored", () => location.reload());
+ }
+ mesh(data, stride = 11) {
+ const gl = this.gl,
+ vao = gl.createVertexArray(),
+ buffer = gl.createBuffer();
+ gl.bindVertexArray(vao);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
+ for (const [loc, size, offset] of stride === 3
+ ? [[0, 3, 0]]
+ : [
+ [0, 3, 0],
+ [1, 3, 3],
+ [2, 3, 6],
+ [3, 1, 9],
+ [4, 1, 10],
+ ]) {
+ gl.enableVertexAttribArray(loc);
+ gl.vertexAttribPointer(
+ loc,
+ size,
+ gl.FLOAT,
+ false,
+ stride * 4,
+ offset * 4,
+ );
+ }
+ return { vao, buffer, count: data.length / stride };
+ }
+ disposeMesh(mesh) {
+ if (mesh.transparent) this.disposeMesh(mesh.transparent);
+ this.gl.deleteBuffer(mesh.buffer);
+ this.gl.deleteVertexArray(mesh.vao);
+ }
+ upload(mesh, data, stride = 11) {
+ const gl = this.gl;
+ gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
+ mesh.count = data.length / stride;
+ }
+ setBounceShader(source) {
+ if (source.length > 32768) throw Error("Шейдер пакета слишком велик");
+ const next = program(
+ this.gl,
+ vertex,
+ fragment.replace(
+ "vec3 shacraftBounceTint(vec3 color,float pulse){return color;}",
+ source,
+ ),
+ );
+ this.gl.deleteProgram(this.program);
+ this.program = next;
+ }
+ async setTexture(blob, effect = false) {
+ const bitmap = await createImageBitmap(blob);
+ this.gl.bindTexture(
+ this.gl.TEXTURE_2D,
+ effect ? this.effectTexture : this.texture,
+ );
+ this.gl.texImage2D(
+ this.gl.TEXTURE_2D,
+ 0,
+ this.gl.RGBA,
+ this.gl.RGBA,
+ this.gl.UNSIGNED_BYTE,
+ bitmap,
+ );
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_WRAP_S,
+ this.gl.REPEAT,
+ );
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_WRAP_T,
+ this.gl.REPEAT,
+ );
+ bitmap.close();
+ if (effect) this.effectTextured = true;
+ else this.textured = true;
+ }
+ replace(blocks, materials) {
+ for (const m of this.sections.values()) this.disposeMesh(m);
+ this.sections.clear();
+ this.dirty.clear();
+ this.blocks = blocks;
+ this.materials = materials;
+ for (const k of blocks.keys())
+ this.dirty.add(section(k.split(",").map(Number)));
+ }
+ change(changes) {
+ for (const { pos } of changes) {
+ this.dirty.add(section(pos));
+ for (const f of faces)
+ this.dirty.add(section(pos.map((v, i) => v + f.n[i])));
+ }
+ }
+ invalidate() {
+ for (const k of this.blocks.keys())
+ this.dirty.add(section(k.split(",").map(Number)));
+ }
+ rebuild(id) {
+ const base = id.split(",").map((v) => Number(v) * 16),
+ vertices = [],
+ transparent = [];
+ for (let x = base[0]; x < base[0] + 16; x++)
+ for (let y = base[1]; y < base[1] + 16; y++)
+ for (let z = base[2]; z < base[2] + 16; z++) {
+ const pos = [x, y, z],
+ block = this.blocks.get(key(pos));
+ if (!block) continue;
+ const mat = this.materials.get(block) || {
+ color: [150, 152, 142],
+ render: [unitBox],
+ },
+ color = mat.color.map((v) => v / 255),
+ full = isFullCube(mat);
+ for (const box of mat.render || [unitBox]) {
+ boxVertices(
+ mat.opacity < 1 ? transparent : vertices,
+ pos,
+ box,
+ color,
+ materialType(mat.state, mat.effect),
+ (n) =>
+ full &&
+ isFullCube(
+ this.materials.get(
+ this.blocks.get(key(pos.map((v, i) => v + n[i]))),
+ ),
+ ),
+ 0,
+ mat.opacity ?? 1,
+ );
+ }
+ }
+ let mesh = this.sections.get(id);
+ if (mesh) this.upload(mesh, vertices);
+ else if (vertices.length || transparent.length) {
+ mesh = this.mesh(vertices);
+ this.sections.set(id, mesh);
+ }
+ if (mesh) {
+ if (mesh.transparent) this.upload(mesh.transparent, transparent);
+ else if (transparent.length) mesh.transparent = this.mesh(transparent);
+ }
+ if (mesh && vertices.length === 0 && transparent.length === 0) {
+ this.disposeMesh(mesh);
+ this.sections.delete(id);
+ }
+ }
+ updateSelection(hit) {
+ const a = [];
+ if (hit) {
+ const mat = this.materials.get(hit.block),
+ boxes = mat?.render?.length ? mat.render : [unitBox];
+ for (const box of boxes) {
+ const points = [];
+ for (let i = 0; i < 8; i++)
+ points.push(
+ [0, 1, 2].map(
+ (k) =>
+ hit.pos[k] +
+ ((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003),
+ ),
+ );
+ for (let i = 0; i < 8; i++)
+ for (let axis = 0; axis < 3; axis++)
+ if (!(i & (1 << axis)))
+ a.push(...points[i], ...points[i | (1 << axis)]);
+ }
+ }
+ this.upload(this.lines, a, 3);
+ }
+ draw(camera, players, entities, entityDefinitions) {
+ if (this.lost) return;
+ const gl = this.gl,
+ ratio = Math.min(devicePixelRatio || 1, 1.75),
+ w = Math.floor(this.canvas.clientWidth * ratio),
+ h = Math.floor(this.canvas.clientHeight * ratio);
+ if (this.canvas.width !== w || this.canvas.height !== h) {
+ this.canvas.width = w;
+ this.canvas.height = h;
+ }
+ gl.viewport(0, 0, w, h);
+ gl.clearColor(0.65, 0.79, 0.8, 1);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+ gl.disable(gl.DEPTH_TEST);
+ gl.disable(gl.CULL_FACE);
+ gl.useProgram(this.sky);
+ gl.uniform1f(gl.getUniformLocation(this.sky, "uPitch"), camera.pitch);
+ gl.uniform1f(gl.getUniformLocation(this.sky, "uYaw"), camera.yaw);
+ gl.uniform1f(gl.getUniformLocation(this.sky, "uAspect"), w / h);
+ gl.bindVertexArray(null);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ this.vp = multiply(
+ perspective(Math.PI / 2.7, w / h, 0.06, 190),
+ viewMatrix(camera.eye, camera.yaw, camera.pitch),
+ );
+ const start = performance.now();
+ let rebuilt = 0;
+ for (const id of this.dirty) {
+ this.rebuild(id);
+ this.dirty.delete(id);
+ rebuilt++;
+ if (rebuilt >= 3 || performance.now() - start > 7) break;
+ }
+ gl.enable(gl.DEPTH_TEST);
+ gl.enable(gl.CULL_FACE);
+ gl.cullFace(gl.BACK);
+ gl.frontFace(gl.CCW);
+ gl.useProgram(this.program);
+ gl.uniformMatrix4fv(
+ gl.getUniformLocation(this.program, "uVP"),
+ false,
+ this.vp,
+ );
+ gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), camera.eye);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0);
+ gl.uniform1i(
+ gl.getUniformLocation(this.program, "uTextured"),
+ this.textured ? 1 : 0,
+ );
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
+ gl.uniform1i(gl.getUniformLocation(this.program, "uEffectTexture"), 1);
+ gl.uniform1i(
+ gl.getUniformLocation(this.program, "uEffectTextured"),
+ this.effectTextured ? 1 : 0,
+ );
+ gl.uniform1f(
+ gl.getUniformLocation(this.program, "uPulse"),
+ Math.max(0, 1 - (performance.now() - this.bounceAt) / 600),
+ );
+ this.triangles = 0;
+ for (const [id, m] of this.sections) {
+ const center = id.split(",").map((v) => Number(v) * 16 + 8);
+ if (
+ Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > 135
+ )
+ continue;
+ gl.bindVertexArray(m.vao);
+ gl.drawArrays(gl.TRIANGLES, 0, m.count);
+ this.triangles += m.count / 3;
+ }
+ const dynamic = [];
+ for (const p of players) {
+ const c = p.color || [0.29, 0.46, 0.36],
+ yaw = p.yaw || 0;
+ boxVertices(
+ dynamic,
+ p.position,
+ { min: [-0.21, 0.63, -0.13], max: [0.21, 1.27, 0.13] },
+ c,
+ 2,
+ () => false,
+ yaw,
+ );
+ boxVertices(
+ dynamic,
+ p.position,
+ { min: [-0.23, 1.27, -0.23], max: [0.23, 1.73, 0.23] },
+ [0.77, 0.61, 0.43],
+ 0,
+ () => false,
+ yaw,
+ );
+ for (const side of [-1, 1]) {
+ boxVertices(
+ dynamic,
+ p.position,
+ {
+ min: [side * 0.13 - 0.085, 0.02, -0.09],
+ max: [side * 0.13 + 0.085, 0.63, 0.09],
+ },
+ [0.25, 0.3, 0.32],
+ 0,
+ () => false,
+ yaw,
+ );
+ boxVertices(
+ dynamic,
+ p.position,
+ {
+ min: [side * 0.31 - 0.085, 0.63, -0.1],
+ max: [side * 0.31 + 0.085, 1.24, 0.1],
+ },
+ c,
+ 0,
+ () => false,
+ yaw,
+ );
+ }
+ boxVertices(
+ dynamic,
+ p.position,
+ { min: [-0.18, 1.56, -0.234], max: [0.18, 1.64, -0.226] },
+ [0.13, 0.19, 0.15],
+ 0,
+ () => false,
+ yaw,
+ );
+ }
+ for (const e of entities) {
+ const def = entityDefinitions.get(e.kind || e.name) || {},
+ color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255),
+ boxes = e.render ||
+ def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }];
+ for (const b of boxes)
+ boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0);
+ }
+ this.upload(this.dynamic, dynamic);
+ gl.bindVertexArray(this.dynamic.vao);
+ gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
+ this.triangles += this.dynamic.count / 3;
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ gl.depthMask(false);
+ const translucent = [...this.sections]
+ .filter(([, m]) => m.transparent?.count)
+ .sort((a, b) => {
+ const dist = (id) =>
+ Math.hypot(
+ ...id.split(",").map((v, i) => Number(v) * 16 + 8 - camera.eye[i]),
+ );
+ return dist(b[0]) - dist(a[0]);
+ });
+ for (const [, m] of translucent) {
+ gl.bindVertexArray(m.transparent.vao);
+ gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count);
+ this.triangles += m.transparent.count / 3;
+ }
+ gl.depthMask(true);
+ gl.disable(gl.BLEND);
+ if (this.lines.count) {
+ gl.useProgram(this.line);
+ gl.uniformMatrix4fv(
+ gl.getUniformLocation(this.line, "uVP"),
+ false,
+ this.vp,
+ );
+ gl.bindVertexArray(this.lines.vao);
+ gl.drawArrays(gl.LINES, 0, this.lines.count);
+ }
+ gl.bindVertexArray(null);
+ }
+ project(p) {
+ return project(
+ p,
+ this.vp,
+ this.canvas.clientWidth,
+ this.canvas.clientHeight,
+ );
+ }
+}
diff --git a/client/style.css b/client/style.css
new file mode 100644
index 0000000..6ded951
--- /dev/null
+++ b/client/style.css
@@ -0,0 +1,597 @@
+:root {
+ font-family: Inter, "Segoe UI", system-ui, sans-serif;
+ color: #f0f2ed;
+ background: #a8c6d0;
+ font-size: 14px;
+ --panel: rgba(28, 35, 31, 0.97);
+ --line: #4a544b;
+ --muted: #b2beb0;
+ --accent: #c0d3a9;
+}
+* {
+ box-sizing: border-box;
+}
+html,
+body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+}
+button,
+input {
+ font: inherit;
+}
+button {
+ color: inherit;
+ background: #39453b;
+ border: 1px solid #5d6a5d;
+ border-radius: 4px;
+ padding: 9px 12px;
+ cursor: pointer;
+}
+button:hover {
+ background: #4c5e4a;
+}
+button:focus-visible,
+input:focus-visible {
+ outline: 2px solid #dfefba;
+ outline-offset: 3px;
+}
+button:disabled {
+ opacity: 0.5;
+ cursor: wait;
+}
+button.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #253021;
+ font-weight: 650;
+}
+input {
+ background: #202821;
+ color: #f4f4ef;
+ border: 1px solid #576255;
+ border-radius: 4px;
+ padding: 10px;
+ width: 100%;
+}
+input[type="range"] {
+ padding: 0;
+ accent-color: #b9ce9e;
+}
+kbd {
+ font-size: 10px;
+ font-family: inherit;
+ color: #d7dfd3;
+ border: 1px solid #82907d75;
+ border-radius: 3px;
+ padding: 2px 4px;
+ background: #17231c55;
+ white-space: nowrap;
+}
+button kbd {
+ margin-left: 6px;
+}
+#world-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ cursor: crosshair;
+}
+.topbar {
+ position: absolute;
+ inset: 22px 24px auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+ pointer-events: none;
+ text-shadow: 0 1px 4px #213a4380;
+}
+.identity {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.identity > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.identity strong {
+ font-size: 19px;
+ letter-spacing: -0.4px;
+ line-height: 1.15;
+}
+.world-name {
+ border: 0;
+ background: none;
+ padding: 0;
+ text-align: left;
+ font-size: 12px;
+ color: #e2ece0;
+ text-shadow: 0 1px 3px #18241d;
+ pointer-events: auto;
+}
+.world-name:hover {
+ background: none;
+ color: white;
+}
+#connection {
+ align-self: center;
+ font-size: 11px;
+ color: #edf4e9;
+ background: #273b2cb5;
+ padding: 5px 8px;
+ border-radius: 3px;
+ margin-left: 10px;
+}
+#connection.bad {
+ background: #713e30;
+}
+nav {
+ display: flex;
+ gap: 7px;
+ pointer-events: auto;
+}
+nav button {
+ background: #26362ed6;
+ border-color: #e0efd333;
+ box-shadow: 0 2px 8px #1d342615;
+ font-size: 12px;
+}
+#menu-button {
+ padding-inline: 13px;
+}
+#crosshair {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 13px;
+ height: 13px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ filter: drop-shadow(0 1px 1px #112019);
+}
+#crosshair:before,
+#crosshair:after {
+ content: "";
+ position: absolute;
+ background: #fffdf5b3;
+}
+#crosshair:before {
+ left: 6px;
+ top: 0;
+ width: 1px;
+ height: 13px;
+}
+#crosshair:after {
+ left: 0;
+ top: 6px;
+ width: 13px;
+ height: 1px;
+}
+#target-label {
+ position: absolute;
+ left: 50%;
+ top: calc(50% + 28px);
+ transform: translateX(-50%);
+ font-size: 11px;
+ text-shadow: 0 1px 3px #172f21;
+ pointer-events: none;
+ opacity: 0.9;
+}
+footer {
+ position: absolute;
+ bottom: 23px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
+ pointer-events: none;
+}
+#selected-name {
+ font-size: 12px;
+ color: #fffdf6;
+ text-shadow: 0 1px 4px #163622;
+ margin-bottom: 9px;
+ min-height: 17px;
+}
+#hotbar {
+ display: flex;
+ gap: 3px;
+ background: #1b2a22b8;
+ border: 1px solid #e0e9d633;
+ padding: 5px;
+ border-radius: 5px;
+ pointer-events: auto;
+ box-shadow: 0 5px 20px #20352730;
+}
+.slot {
+ width: 49px;
+ height: 49px;
+ position: relative;
+ padding: 8px;
+ border: 1px solid #9cac9340;
+ background: #46513b8c;
+ border-radius: 2px;
+ display: grid;
+ place-items: center;
+}
+.slot.active {
+ background: #dce7b82e;
+ border: 2px solid #e6edbc;
+ padding: 7px;
+}
+.slot small {
+ position: absolute;
+ left: 3px;
+ top: 2px;
+ font-size: 9px;
+ color: #e8edde;
+}
+.swatch {
+ display: block;
+ width: 26px;
+ height: 26px;
+ image-rendering: pixelated;
+ background: var(--block-color, #aaaaaa);
+ box-shadow:
+ inset -6px -5px 0 #0003,
+ inset 4px 4px 0 #fff2;
+ transform: rotate(-2deg);
+}
+#play-hint {
+ font-size: 11px;
+ text-shadow: 0 1px 4px #15271c;
+ margin-top: 9px;
+ color: #e3e8d7;
+ white-space: nowrap;
+}
+.bottom-left {
+ position: absolute;
+ left: 24px;
+ bottom: 25px;
+ max-width: min(355px, 30vw);
+ pointer-events: none;
+}
+#hints {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px 11px;
+ color: #e1e9db;
+ font-size: 10px;
+ line-height: 1.7;
+ text-shadow: 0 1px 3px #25392b;
+}
+#chat-log {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 14px;
+ font-size: 12px;
+ text-shadow: 0 1px 3px #000;
+ max-height: 180px;
+ overflow: hidden;
+ word-break: break-word;
+}
+#chat-log div {
+ background: #20332ba6;
+ padding: 5px 8px;
+ border-radius: 3px;
+ width: fit-content;
+}
+#chat-log .system {
+ color: #e3dfb5;
+}
+#chat-form {
+ pointer-events: auto;
+ margin-bottom: 10px;
+ width: min(400px, 80vw);
+}
+#chat-input {
+ font-size: 12px;
+ background: #20352bec;
+}
+#labels {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+}
+.player-label {
+ position: absolute;
+ white-space: nowrap;
+ font-size: 11px;
+ background: #203629a8;
+ padding: 3px 6px;
+ border-radius: 3px;
+ transform: translate(-50%, -100%);
+}
+.panel {
+ position: absolute;
+ top: 86px;
+ right: 24px;
+ width: 315px;
+ max-height: calc(100vh - 116px);
+ overflow: auto;
+ background: var(--panel);
+ border: 1px solid #93a38a66;
+ box-shadow: 0 10px 30px #10281933;
+ border-radius: 6px;
+ padding: 20px;
+}
+.panel-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 18px;
+}
+.panel-head h1 {
+ font-size: 18px;
+ font-weight: 650;
+ margin: 2px 0 0;
+ letter-spacing: -0.3px;
+}
+.panel-head button {
+ background: none;
+ border: 0;
+ font-size: 24px;
+ line-height: 20px;
+ padding: 0 2px;
+ color: #b6c4b1;
+}
+.panel-head p {
+ margin: 5px 0 0;
+}
+.panel label {
+ display: block;
+ font-size: 12px;
+ color: #c8d3c2;
+ margin-bottom: 7px;
+}
+.muted {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.6;
+}
+.wide {
+ display: block;
+ width: 100%;
+ margin-top: 10px;
+}
+.inline {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 20px;
+}
+.inline input {
+ min-width: 0;
+}
+.inline button {
+ font-size: 11px;
+ padding: 8px;
+}
+.rule {
+ border-top: 1px solid var(--line);
+ margin: 20px 0;
+}
+.world-row {
+ display: block;
+ width: 100%;
+ text-align: left;
+ margin-top: 8px;
+ background: #2c382f;
+}
+.world-row small {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ margin-top: 4px;
+}
+.world-row.current {
+ border-color: #bacda4;
+}
+.library {
+ width: min(610px, calc(100vw - 48px));
+ padding-bottom: 16px;
+}
+.library #catalog-search {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+#catalog-list {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 7px;
+ max-height: 55vh;
+ overflow: auto;
+}
+.catalog-item {
+ display: flex;
+ gap: 9px;
+ align-items: center;
+ min-width: 0;
+ text-align: left;
+ background: #2f3a30;
+ padding: 10px;
+ font-size: 11px;
+}
+.catalog-item .swatch {
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+}
+.catalog-item > span:last-child {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.catalog-item small {
+ display: block;
+ font-size: 9px;
+ color: #aebca6;
+ margin-top: 5px;
+ overflow-wrap: anywhere;
+}
+.catalog-empty {
+ grid-column: 1/-1;
+ color: var(--muted);
+ padding: 20px 0;
+}
+.diagnostics dl {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 9px 10px;
+ font-size: 12px;
+ margin: 0 0 20px;
+}
+.diagnostics dt {
+ color: #aebeaa;
+}
+.diagnostics dd {
+ margin: 0;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+}
+#toast {
+ position: absolute;
+ top: 90px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #263a2cf2;
+ border: 1px solid #b7cba380;
+ border-radius: 4px;
+ padding: 11px 16px;
+ font-size: 13px;
+ max-width: min(580px, 90vw);
+ text-align: center;
+ box-shadow: 0 2px 10px #0002;
+}
+#toast.error {
+ background: #633c2ff5;
+ border-color: #d0a68f;
+}
+#match {
+ position: absolute;
+ top: 92px;
+ left: 24px;
+ background: #26392cd6;
+ border: 1px solid #e2e3c24a;
+ padding: 12px 16px;
+ border-radius: 4px;
+ font-size: 13px;
+ max-width: 360px;
+}
+#fatal {
+ position: absolute;
+ inset: 30% 20% auto;
+ background: #312e25ed;
+ border: 1px solid #d4b696;
+ padding: 30px;
+ line-height: 1.6;
+ border-radius: 5px;
+}
+body.locked .topbar,
+body.locked #hints,
+body.locked #play-hint {
+ opacity: 0.35;
+}
+body.locked .topbar:hover {
+ opacity: 1;
+}
+[hidden] {
+ display: none !important;
+}
+@media (max-width: 850px) {
+ .topbar {
+ inset: 14px 14px auto;
+ }
+ .identity {
+ gap: 8px;
+ }
+ #connection {
+ margin-left: 1px;
+ font-size: 10px;
+ }
+ nav button {
+ font-size: 11px;
+ padding: 9px 8px;
+ }
+ nav kbd {
+ display: none;
+ }
+ .bottom-left {
+ left: 14px;
+ bottom: 115px;
+ max-width: 75vw;
+ }
+ #hints {
+ display: none;
+ }
+ footer {
+ bottom: 14px;
+ }
+ .panel {
+ right: 14px;
+ top: 77px;
+ max-height: calc(100vh - 94px);
+ }
+ .slot {
+ width: 40px;
+ height: 44px;
+ }
+ #play-hint {
+ white-space: normal;
+ width: 360px;
+ font-size: 10px;
+ }
+ #catalog-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+@media (max-width: 520px) {
+ .identity strong {
+ font-size: 15px;
+ }
+ .identity svg {
+ width: 22px;
+ }
+ .topbar {
+ gap: 6px;
+ }
+ #connection {
+ display: none;
+ }
+ #diagnostics-button {
+ font-size: 0;
+ }
+ #diagnostics-button:before {
+ content: "F3";
+ font-size: 12px;
+ }
+ .slot {
+ width: 34px;
+ height: 40px;
+ padding: 6px;
+ }
+ .swatch {
+ width: 23px;
+ height: 23px;
+ }
+ .library {
+ width: calc(100vw - 28px);
+ }
+ #target-label {
+ max-width: 90vw;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ #fatal {
+ inset: 25% 14px auto;
+ }
+}
diff --git a/client/tests/math.test.js b/client/tests/math.test.js
new file mode 100644
index 0000000..d33d489
--- /dev/null
+++ b/client/tests/math.test.js
@@ -0,0 +1,72 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ direction,
+ raycast,
+ rayBox,
+ multiply,
+ perspective,
+ viewMatrix,
+ project,
+ unitBox,
+} from "../math.js";
+test("camera axes and world projection match the server coordinate convention", () => {
+ assert.deepEqual(direction(0, 0), [0, 0, -1]);
+ const east = direction(Math.PI / 2, 0);
+ assert.ok(Math.abs(east[0] - 1) < 1e-8);
+ const vp = multiply(
+ perspective(Math.PI / 2, 1, 0.1, 100),
+ viewMatrix([0, 2, 8], 0, 0),
+ );
+ const center = project([0, 2, 0], vp, 100, 100);
+ assert.ok(Math.abs(center[0] - 50) < 1e-5 && Math.abs(center[1] - 50) < 1e-5);
+ assert.equal(project([0, 2, 20], vp, 100, 100), null);
+});
+test("DDA resolves negative world coordinates and nearest entry face", () => {
+ const blocks = new Map([
+ ["-1,0,-2", 1],
+ ["-1,0,-4", 1],
+ ]),
+ materials = new Map([[1, { render: [unitBox] }]]);
+ const hit = raycast([-0.5, 0.5, 0], [0, 0, -1], blocks, materials);
+ assert.deepEqual(hit.pos, [-1, 0, -2]);
+ assert.deepEqual(hit.normal, [0, 0, 1]);
+ assert.equal(hit.distance, 1);
+});
+test("ray selection respects slab geometry and reach instead of selecting empty half", () => {
+ const blocks = new Map([["0,0,-2", 1]]),
+ materials = new Map([
+ [1, { render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }] }],
+ ]);
+ assert.equal(raycast([0.5, 0.75, 0], [0, 0, -1], blocks, materials), null);
+ assert.ok(raycast([0.5, 0.25, 0], [0, 0, -1], blocks, materials));
+ assert.equal(
+ raycast([0.5, 0.25, 10], [0, 0, -1], blocks, materials, 6),
+ null,
+ );
+});
+test("axis aligned ray slabs reject parallel paths outside a box", () => {
+ assert.equal(rayBox([2, 0.5, 3], [0, 0, -1], [0, 0, 0], [1, 1, 1], 6), null);
+ assert.equal(
+ rayBox([0.5, 0.5, 3], [0, 0, -1], [0, 0, 0], [1, 1, 1], 6).distance,
+ 2,
+ );
+});
+
+test("selection intersects a fence extension owned by the cell below", () => {
+ const blocks = new Map([["0,0,-2", 1]]),
+ materials = new Map([
+ [1, { render: [{ min: [0.375, 0, 0.375], max: [0.625, 1.5, 0.625] }] }],
+ ]);
+ const hit = raycast([0.5, 1.25, 0], [0, 0, -1], blocks, materials);
+ assert.deepEqual(hit.pos, [0, 0, -2]);
+ assert.deepEqual(hit.normal, [0, 0, 1]);
+ assert.equal(hit.distance, 1.375);
+});
+test("package geometry is selectable across its documented three-block extent", () => {
+ const blocks = new Map([["0,0,0", 1]]);
+ const materials = new Map([[1, { render: [{min:[0,0,0],max:[1,3,1]}] }]]);
+ const hit = raycast([0.5,2.5,4], [0,0,-1], blocks, materials, 6);
+ assert.deepEqual(hit.pos,[0,0,0]);
+ assert.deepEqual(hit.normal,[0,0,1]);
+});
diff --git a/client/tests/renderer-smoke.html b/client/tests/renderer-smoke.html
new file mode 100644
index 0000000..0c14d22
--- /dev/null
+++ b/client/tests/renderer-smoke.html
@@ -0,0 +1,64 @@
+Shacraft renderer fixture
+
diff --git a/crates/shacraft-compat/Cargo.toml b/crates/shacraft-compat/Cargo.toml
new file mode 100644
index 0000000..edaa168
--- /dev/null
+++ b/crates/shacraft-compat/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "shacraft-compat"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+clap.workspace = true
+shacraft-core.workspace = true
+flate2 = "1"
+sha2 = "0.10"
+tempfile = "3"
+cesu8 = "1"
+lz4_flex = "0.13"
+xxhash-rust = { version = "0.8", features = ["xxh32"] }
+rustix = { version = "1", features = ["fs"] }
+rusqlite = { version = "=0.40.2", features = ["bundled"] }
+
+[dev-dependencies]
diff --git a/crates/shacraft-compat/fixtures/java26_2-world/custom-metadata.txt b/crates/shacraft-compat/fixtures/java26_2-world/custom-metadata.txt
new file mode 100644
index 0000000..ed2066d
--- /dev/null
+++ b/crates/shacraft-compat/fixtures/java26_2-world/custom-metadata.txt
@@ -0,0 +1 @@
+own fixture; retain unknown file
diff --git a/crates/shacraft-compat/fixtures/java26_2-world/data/minecraft/game_rules.dat b/crates/shacraft-compat/fixtures/java26_2-world/data/minecraft/game_rules.dat
new file mode 100644
index 0000000..64edf80
Binary files /dev/null and b/crates/shacraft-compat/fixtures/java26_2-world/data/minecraft/game_rules.dat differ
diff --git a/crates/shacraft-compat/fixtures/java26_2-world/data/minecraft/world_gen_settings.dat b/crates/shacraft-compat/fixtures/java26_2-world/data/minecraft/world_gen_settings.dat
new file mode 100644
index 0000000..d45181f
Binary files /dev/null and b/crates/shacraft-compat/fixtures/java26_2-world/data/minecraft/world_gen_settings.dat differ
diff --git a/crates/shacraft-compat/fixtures/java26_2-world/entities/r.-1.0.mca b/crates/shacraft-compat/fixtures/java26_2-world/entities/r.-1.0.mca
new file mode 100644
index 0000000..91817d5
Binary files /dev/null and b/crates/shacraft-compat/fixtures/java26_2-world/entities/r.-1.0.mca differ
diff --git a/crates/shacraft-compat/fixtures/java26_2-world/level.dat b/crates/shacraft-compat/fixtures/java26_2-world/level.dat
new file mode 100644
index 0000000..20713bd
Binary files /dev/null and b/crates/shacraft-compat/fixtures/java26_2-world/level.dat differ
diff --git a/crates/shacraft-compat/fixtures/java26_2-world/region/r.-1.0.mca b/crates/shacraft-compat/fixtures/java26_2-world/region/r.-1.0.mca
new file mode 100644
index 0000000..2657ad2
Binary files /dev/null and b/crates/shacraft-compat/fixtures/java26_2-world/region/r.-1.0.mca differ
diff --git a/crates/shacraft-compat/fixtures/provenance.json b/crates/shacraft-compat/fixtures/provenance.json
new file mode 100644
index 0000000..770aa32
--- /dev/null
+++ b/crates/shacraft-compat/fixtures/provenance.json
@@ -0,0 +1,55 @@
+{
+ "schema_version": 1,
+ "source": "Own minimal fixtures; generated through public Java26.2 APIs and nbtlib2.0.4, no game assets",
+ "minecraft_version": "26.2",
+ "data_version": 4903,
+ "java_runtime": "Microsoft OpenJDK25.0.1+8",
+ "server_jar_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
+ "server_jar_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5",
+ "generator_files": [
+ "scripts/compat_java.java",
+ "scripts/compat_fixtures.py"
+ ],
+ "files": {
+ "java26_2-world/custom-metadata.txt": {
+ "bytes": 33,
+ "sha256": "9d6851a4b4ad57e9a1a49d3e709cfe4de698ddb1c9add85c3afd2e38bac90ac5"
+ },
+ "java26_2-world/data/minecraft/game_rules.dat": {
+ "bytes": 821,
+ "sha256": "566d8bf7c24388101264e650bcfad24f79446758adcaf505fe5276aa0484b9ba"
+ },
+ "java26_2-world/data/minecraft/world_gen_settings.dat": {
+ "bytes": 301,
+ "sha256": "0c100210ac72c9b390c1d64287e3a57bfb40cb39c2651948d38b4d223d5c296d"
+ },
+ "java26_2-world/entities/r.-1.0.mca": {
+ "bytes": 12288,
+ "sha256": "c53c1c8c0e75ad16d3a2c7744fc92e2df4115a0423972846796d8385be831a80"
+ },
+ "java26_2-world/level.dat": {
+ "bytes": 309,
+ "sha256": "e22ec9c3da109bdb6f6c0677712d8a7767eee0d4b2b45dc953113d34b40885df"
+ },
+ "java26_2-world/region/r.-1.0.mca": {
+ "bytes": 12288,
+ "sha256": "6fc56ee0177ca0ab8eab1c1858a0641ed2efaa8230f25e6f012ec4ff42dd9424"
+ },
+ "sponge-v3.schem": {
+ "bytes": 648,
+ "sha256": "cd4041dce3e12af60c80931b730d89b8fef844fe65e1c5dcca12ba6f656d5801"
+ },
+ "world-template/data/minecraft/game_rules.dat": {
+ "bytes": 821,
+ "sha256": "a2fe317c7838ab52ac17d1d4ad74491351783bdf71de5707d2a47328e849e646"
+ },
+ "world-template/data/minecraft/world_gen_settings.dat": {
+ "bytes": 301,
+ "sha256": "0c100210ac72c9b390c1d64287e3a57bfb40cb39c2651948d38b4d223d5c296d"
+ },
+ "world-template/level.dat": {
+ "bytes": 312,
+ "sha256": "b97ea770237150db83d5ed398086b1ca5d4d9de5a5a25fd167f971a89ae59cf4"
+ }
+ }
+}
diff --git a/crates/shacraft-compat/fixtures/sponge-v3.schem b/crates/shacraft-compat/fixtures/sponge-v3.schem
new file mode 100644
index 0000000..cb3e30e
Binary files /dev/null and b/crates/shacraft-compat/fixtures/sponge-v3.schem differ
diff --git a/crates/shacraft-compat/fixtures/world-template/data/minecraft/game_rules.dat b/crates/shacraft-compat/fixtures/world-template/data/minecraft/game_rules.dat
new file mode 100644
index 0000000..e295c2e
Binary files /dev/null and b/crates/shacraft-compat/fixtures/world-template/data/minecraft/game_rules.dat differ
diff --git a/crates/shacraft-compat/fixtures/world-template/data/minecraft/world_gen_settings.dat b/crates/shacraft-compat/fixtures/world-template/data/minecraft/world_gen_settings.dat
new file mode 100644
index 0000000..d45181f
Binary files /dev/null and b/crates/shacraft-compat/fixtures/world-template/data/minecraft/world_gen_settings.dat differ
diff --git a/crates/shacraft-compat/fixtures/world-template/level.dat b/crates/shacraft-compat/fixtures/world-template/level.dat
new file mode 100644
index 0000000..018617b
Binary files /dev/null and b/crates/shacraft-compat/fixtures/world-template/level.dat differ
diff --git a/crates/shacraft-compat/src/anvil.rs b/crates/shacraft-compat/src/anvil.rs
new file mode 100644
index 0000000..301ee85
--- /dev/null
+++ b/crates/shacraft-compat/src/anvil.rs
@@ -0,0 +1,1033 @@
+use crate::{
+ DATA_VERSION, Dimension, Mode, Provenance, Report, io,
+ nbt::{self, Compound, Tag, compound, get},
+ provenance, section_cells,
+};
+use anyhow::{Context, Result, bail, ensure};
+use shacraft_core::{BlockChange, WorldStore};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs::{self, File, OpenOptions},
+ io::{Read, Seek, SeekFrom, Write},
+ path::{Path, PathBuf},
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+pub(crate) struct Region {
+ file: File,
+ path: PathBuf,
+ rx: i32,
+ rz: i32,
+ locations: [u32; 1024],
+}
+fn region_coords(path: &Path) -> Result<(i32, i32)> {
+ let n = path
+ .file_name()
+ .and_then(|s| s.to_str())
+ .context("region_filename")?;
+ let a: Vec<_> = n.split('.').collect();
+ ensure!(
+ a.len() == 4 && a[0] == "r" && a[3] == "mca",
+ "region_filename: {n}"
+ );
+ Ok((a[1].parse()?, a[2].parse()?))
+}
+impl Region {
+ pub(crate) fn open(path: &Path, write: bool) -> Result {
+ let (rx, rz) = region_coords(path)?;
+ let mut file = OpenOptions::new().read(true).write(write).open(path)?;
+ let len = file.metadata()?.len();
+ ensure!(
+ (8192..=8 * 1024 * 1024 * 1024).contains(&len) && len % 4096 == 0,
+ "region_length: {}",
+ path.display()
+ );
+ let mut header = [0; 4096];
+ file.read_exact(&mut header)?;
+ let mut locations = [0; 1024];
+ let mut extents = Vec::new();
+ for (i, b) in header.chunks_exact(4).enumerate() {
+ let n = u32::from_be_bytes(b.try_into()?);
+ locations[i] = n;
+ if n != 0 {
+ let start = (n >> 8) as u64;
+ let count = (n & 255) as u64;
+ ensure!(
+ start >= 2 && count > 0 && (start + count) * 4096 <= len,
+ "region_location: outside file"
+ );
+ extents.push((start, start + count))
+ }
+ }
+ extents.sort_unstable();
+ ensure!(
+ extents.windows(2).all(|w| w[0].1 <= w[1].0),
+ "region_overlap: overlapping chunk sectors"
+ );
+ Ok(Self {
+ file,
+ path: path.into(),
+ rx,
+ rz,
+ locations,
+ })
+ }
+ pub(crate) fn create(path: &Path) -> Result {
+ fs::create_dir_all(path.parent().context("region parent")?)?;
+ let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
+ f.write_all(&[0; 8192])?;
+ f.sync_all()?;
+ Self::open(path, true)
+ }
+ fn coordinates(&self, index: usize) -> Result<(i32, i32)> {
+ let x = self
+ .rx
+ .checked_mul(32)
+ .and_then(|v| v.checked_add((index % 32) as i32))
+ .context("region_coordinate_overflow")?;
+ let z = self
+ .rz
+ .checked_mul(32)
+ .and_then(|v| v.checked_add((index / 32) as i32))
+ .context("region_coordinate_overflow")?;
+ ensure!(
+ (-1_875_000..=1_874_999).contains(&x) && (-1_875_000..=1_874_999).contains(&z),
+ "region_coordinate_limit"
+ );
+ Ok((x, z))
+ }
+ pub(crate) fn read(&mut self, index: usize) -> Result