feat(core): add compact durable world storage and verified CLI
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
name: Core checks
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
storage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.96.0
|
||||
components: rustfmt, clippy
|
||||
- name: Verify storage and crash recovery
|
||||
run: bash scripts/verify.sh
|
||||
|
||||
@@ -3,4 +3,7 @@
|
||||
/artifacts/
|
||||
*.token
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
/graphify-out/
|
||||
|
||||
|
||||
Generated
+682
@@ -0,0 +1,682 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fs2",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-tools"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shacraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/shacraft-core", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-compat"]
|
||||
members = ["crates/shacraft-core", "crates/shacraft-tools"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
@@ -13,4 +13,3 @@ serde_json = "1"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
shacraft-core = { path = "crates/shacraft-core" }
|
||||
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Shacraft Core contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
|
||||
Проект начинается заново локально: исходники из зависшей облачной среды не восстановлены. Сведения о ранее выполненных сборках и тестах не являются результатами этого репозитория.
|
||||
|
||||
## Текущий результат: этап 1 — хранилище
|
||||
|
||||
Реализованы самостоятельная Rust-библиотека и локальный CLI: компактные секции, ограниченный кэш, сохранение, неизменяемые снимки, независимые экземпляры миров, атомарные правки, ревизии, идемпотентность, undo/reset и диагностика. Это основа движка; игровой сервер, графический клиент, MCP, каталог Minecraft и конвертер ещё предстоят.
|
||||
|
||||
```bash
|
||||
cd /home/emil/Desktop/shacraft-core
|
||||
bash scripts/verify.sh
|
||||
cargo run -p shacraft-tools -- --data data/demo demo
|
||||
```
|
||||
|
||||
Для повторной демонстрации укажите новый пустой каталог. [Команды, измерение и JSON-lines API](docs/DEVELOPMENT.md). JSON-lines API предназначен для локальной проверки хранилища и не является MCP.
|
||||
|
||||
Код доступен под MIT или Apache-2.0 на выбор. Оригинальные ресурсы Minecraft в проект не входят.
|
||||
|
||||
## Контекст и порядок чтения
|
||||
|
||||
1. [Требования](docs/REQUIREMENTS.md) — что запросил пользователь.
|
||||
@@ -14,4 +28,3 @@
|
||||
6. [Память и хранение](docs/MEMORY_AND_STORAGE.md), [совместимость](docs/COMPATIBILITY.md), [приёмка](docs/ACCEPTANCE.md).
|
||||
|
||||
Работа ведётся локально в `/home/emil/Desktop/shacraft-core`. Продакшен-серверы и существующие проекты лаунчера не изменяются автоматически.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "shacraft-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rusqlite = { version = "=0.40.2", features = ["bundled"] }
|
||||
fs2 = "0.4"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
pub(crate) struct SectionCache {
|
||||
entries: HashMap<i64, Vec<u8>>,
|
||||
recency: VecDeque<i64>,
|
||||
pub capacity: usize,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub evictions: u64,
|
||||
}
|
||||
|
||||
impl SectionCache {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
recency: VecDeque::new(),
|
||||
capacity,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
evictions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: i64) -> Option<&[u8]> {
|
||||
if self.entries.contains_key(&id) {
|
||||
self.hits = self.hits.saturating_add(1);
|
||||
if let Some(index) = self.recency.iter().position(|entry| *entry == id) {
|
||||
self.recency.remove(index);
|
||||
}
|
||||
self.recency.push_back(id);
|
||||
self.entries.get(&id).map(Vec::as_slice)
|
||||
} else {
|
||||
self.misses = self.misses.saturating_add(1);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: i64, bytes: Vec<u8>) {
|
||||
if self.entries.contains_key(&id) {
|
||||
return;
|
||||
}
|
||||
while self.entries.len() >= self.capacity {
|
||||
if let Some(evicted) = self.recency.pop_front() {
|
||||
self.entries.remove(&evicted);
|
||||
self.evictions = self.evictions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
self.entries.insert(id, bytes);
|
||||
self.recency.push_back(id);
|
||||
}
|
||||
|
||||
pub fn peek(&self, id: i64) -> Option<&[u8]> {
|
||||
self.entries.get(&id).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn payload_bytes(&self) -> usize {
|
||||
self.entries.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.recency.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use rusqlite::{Connection, TransactionBehavior};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const SQLITE_CACHE_KIB: i64 = 4096;
|
||||
const APPLICATION_ID: i64 = 0x5348_4352;
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
pub(crate) fn configure(conn: &mut Connection) -> Result<()> {
|
||||
conn.busy_timeout(Duration::from_secs(5))?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
let app: i64 = conn.pragma_query_value(None, "application_id", |row| row.get(0))?;
|
||||
let table_count: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if version == 0 && app == 0 && table_count == 0 {
|
||||
// New database; schema is created below after durability settings.
|
||||
} else {
|
||||
ensure!(
|
||||
app == APPLICATION_ID,
|
||||
"storage_format: unexpected application_id"
|
||||
);
|
||||
ensure!(
|
||||
version == SCHEMA_VERSION,
|
||||
"storage_format: unsupported schema version {version}"
|
||||
);
|
||||
}
|
||||
|
||||
let mode: String = conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
|
||||
ensure!(
|
||||
mode.eq_ignore_ascii_case("wal"),
|
||||
"storage_config: WAL unavailable"
|
||||
);
|
||||
conn.pragma_update(None, "synchronous", "FULL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.pragma_update(None, "cache_size", -SQLITE_CACHE_KIB)?;
|
||||
conn.pragma_update(None, "temp_store", "FILE")?;
|
||||
conn.pragma_update(None, "mmap_size", 0)?;
|
||||
conn.pragma_update(None, "wal_autocheckpoint", 256)?;
|
||||
conn.pragma_update(None, "journal_size_limit", 16 * 1024 * 1024)?;
|
||||
let synchronous: i64 = conn.pragma_query_value(None, "synchronous", |r| r.get(0))?;
|
||||
ensure!(
|
||||
synchronous == 2,
|
||||
"storage_config: synchronous FULL not active"
|
||||
);
|
||||
|
||||
if version == 0 {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE registry (
|
||||
id INTEGER PRIMARY KEY CHECK(id >= 0 AND id <= 4294967295),
|
||||
state TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE blobs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
digest BLOB NOT NULL UNIQUE CHECK(length(digest)=32),
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE worlds (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
|
||||
epoch INTEGER NOT NULL DEFAULT 0 CHECK(epoch >= 0),
|
||||
base_snapshot INTEGER REFERENCES snapshots(id),
|
||||
template_name TEXT
|
||||
);
|
||||
CREATE TABLE snapshots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_world INTEGER NOT NULL REFERENCES worlds(id),
|
||||
source_revision INTEGER NOT NULL CHECK(source_revision >= 0),
|
||||
UNIQUE(source_world,source_revision)
|
||||
);
|
||||
CREATE TABLE snapshot_sections (
|
||||
snapshot_id INTEGER NOT NULL REFERENCES snapshots(id),
|
||||
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
|
||||
blob_id INTEGER NOT NULL REFERENCES blobs(id),
|
||||
PRIMARY KEY(snapshot_id,x,y,z)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX snapshot_blob_idx ON snapshot_sections(blob_id);
|
||||
CREATE TABLE world_sections (
|
||||
world_id INTEGER NOT NULL REFERENCES worlds(id),
|
||||
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
|
||||
blob_id INTEGER NOT NULL REFERENCES blobs(id),
|
||||
changed_cells INTEGER NOT NULL CHECK(changed_cells > 0 AND changed_cells <= 4096),
|
||||
PRIMARY KEY(world_id,x,y,z)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX world_blob_idx ON world_sections(blob_id);
|
||||
CREATE TABLE operations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
world_id INTEGER NOT NULL REFERENCES worlds(id),
|
||||
operation_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('edit','undo','reset')),
|
||||
fingerprint BLOB NOT NULL CHECK(length(fingerprint)=32),
|
||||
revision INTEGER NOT NULL CHECK(revision > 0),
|
||||
epoch INTEGER NOT NULL CHECK(epoch >= 0),
|
||||
changed INTEGER NOT NULL CHECK(changed >= 0),
|
||||
UNIQUE(world_id,operation_id)
|
||||
);
|
||||
CREATE TABLE operation_changes (
|
||||
operation INTEGER NOT NULL REFERENCES operations(id),
|
||||
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
|
||||
before_id INTEGER NOT NULL REFERENCES registry(id),
|
||||
after_id INTEGER NOT NULL REFERENCES registry(id),
|
||||
PRIMARY KEY(operation,x,y,z)
|
||||
) WITHOUT ROWID;
|
||||
INSERT INTO registry(id,state) VALUES(0,'minecraft:air');",
|
||||
)?;
|
||||
tx.pragma_update(None, "application_id", APPLICATION_ID)?;
|
||||
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
tx.commit().context("storage_commit: initialize schema")?;
|
||||
}
|
||||
let check: String = conn.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
|
||||
if check != "ok" {
|
||||
bail!("storage_corrupt: {check}");
|
||||
}
|
||||
let mut fk = conn.prepare("PRAGMA foreign_key_check")?;
|
||||
ensure!(
|
||||
fk.query([])?.next()?.is_none(),
|
||||
"storage_corrupt: foreign key violation"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Durable voxel storage with immutable snapshots and bounded section caching.
|
||||
//!
|
||||
//! `WorldStore::open` accepts a directory. Successful mutations commit with
|
||||
//! SQLite WAL + synchronous FULL before returning. Operation IDs are durable
|
||||
//! and scoped to a world; replay returns the original revision. Every new
|
||||
//! accepted edit (including a no-op), undo and reset increments the revision.
|
||||
//! Undo initially supports only the most recent edit's resulting revision.
|
||||
|
||||
mod cache;
|
||||
mod database;
|
||||
mod section;
|
||||
mod store;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::WorldStore;
|
||||
|
||||
pub type Pos = [i32; 3];
|
||||
pub type BlockId = u32;
|
||||
|
||||
pub const COORD_LIMIT: i32 = 30_000_000;
|
||||
pub const MAX_EDIT_CELLS: usize = 32_768;
|
||||
pub const MAX_READ_VOLUME: usize = 262_144;
|
||||
pub const MAX_OPERATION_ID_BYTES: usize = 128;
|
||||
pub const MAX_REGISTRY_STATES: usize = 262_144;
|
||||
pub const MAX_STATE_BYTES: usize = 512;
|
||||
pub const MAX_WORLDS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct BlockChange {
|
||||
pub pos: Pos,
|
||||
pub block: BlockId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct EditResult {
|
||||
pub revision: u64,
|
||||
pub changed: usize,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct WorldInfo {
|
||||
pub name: String,
|
||||
pub revision: u64,
|
||||
pub template: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! A versioned, bounded codec for a 16 × 16 × 16 block section.
|
||||
//!
|
||||
//! The 16-byte header is `SHSC`, version, mode, palette bit width, reserved
|
||||
//! byte, little-endian payload length, and little-endian CRC32. The checksum
|
||||
//! covers the first 12 header bytes followed by the payload. It detects
|
||||
//! accidental corruption; it does not authenticate untrusted data.
|
||||
//!
|
||||
//! Uniform sections contain one `u32`. Palette sections contain a `u16`
|
||||
//! palette length, its `u32` block IDs, and least-significant-bit-first packed
|
||||
//! indices. Dense sections contain 4096 `u32` block IDs. Integers are little
|
||||
//! endian; cell ordering belongs to the caller.
|
||||
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub(crate) const CELL_COUNT: usize = 4096;
|
||||
pub(crate) type Cells = [u32; CELL_COUNT];
|
||||
|
||||
const MAGIC: &[u8; 4] = b"SHSC";
|
||||
const VERSION: u8 = 1;
|
||||
const HEADER_LEN: usize = 16;
|
||||
const UNIFORM: u8 = 0;
|
||||
const PALETTE: u8 = 1;
|
||||
const DENSE: u8 = 2;
|
||||
const DENSE_PAYLOAD_LEN: usize = CELL_COUNT * 4;
|
||||
const MAX_PAYLOAD_LEN: usize = 2 + CELL_COUNT * 4 + CELL_COUNT / 8 * 12;
|
||||
// Absolute bound accepted by v1 decoding; the encoder's dense fallback emits
|
||||
// at most HEADER_LEN + DENSE_PAYLOAD_LEN bytes.
|
||||
pub(crate) const MAX_ENCODED_BYTES: usize = HEADER_LEN + MAX_PAYLOAD_LEN;
|
||||
|
||||
pub(crate) fn encode(cells: &Cells) -> Vec<u8> {
|
||||
if cells.iter().all(|&id| id == cells[0]) {
|
||||
return envelope(UNIFORM, 0, &cells[0].to_le_bytes());
|
||||
}
|
||||
|
||||
// First-occurrence ordering keeps the wire representation deterministic,
|
||||
// independent of the randomized iteration order of HashMap.
|
||||
let mut palette = Vec::new();
|
||||
let mut lookup = HashMap::new();
|
||||
let mut indices = Vec::with_capacity(CELL_COUNT);
|
||||
for &id in cells {
|
||||
let index = *lookup.entry(id).or_insert_with(|| {
|
||||
let index = palette.len() as u16;
|
||||
palette.push(id);
|
||||
index
|
||||
});
|
||||
indices.push(index);
|
||||
}
|
||||
|
||||
let bits = palette_bits(palette.len());
|
||||
let palette_payload_len = 2 + palette.len() * 4 + packed_len(bits);
|
||||
if palette_payload_len > DENSE_PAYLOAD_LEN {
|
||||
let mut payload = Vec::with_capacity(DENSE_PAYLOAD_LEN);
|
||||
for id in cells {
|
||||
payload.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
return envelope(DENSE, 32, &payload);
|
||||
}
|
||||
|
||||
let mut payload = Vec::with_capacity(palette_payload_len);
|
||||
payload.extend_from_slice(&(palette.len() as u16).to_le_bytes());
|
||||
for id in palette {
|
||||
payload.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
let packed_start = payload.len();
|
||||
payload.resize(palette_payload_len, 0);
|
||||
for (cell, index) in indices.into_iter().enumerate() {
|
||||
write_index(&mut payload[packed_start..], cell, bits, index);
|
||||
}
|
||||
envelope(PALETTE, bits, &payload)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], registry_len: usize) -> Result<Box<Cells>> {
|
||||
ensure!(bytes.len() >= HEADER_LEN, "truncated section header");
|
||||
ensure!(&bytes[..4] == MAGIC, "invalid section magic");
|
||||
ensure!(
|
||||
bytes[4] == VERSION,
|
||||
"unsupported section version {}",
|
||||
bytes[4]
|
||||
);
|
||||
ensure!(bytes[7] == 0, "nonzero reserved section header byte");
|
||||
|
||||
let payload_len = read_u32(&bytes[8..12]) as usize;
|
||||
ensure!(
|
||||
payload_len <= MAX_PAYLOAD_LEN,
|
||||
"section payload exceeds maximum size"
|
||||
);
|
||||
let total_len = HEADER_LEN
|
||||
.checked_add(payload_len)
|
||||
.ok_or_else(|| anyhow::anyhow!("section length overflow"))?;
|
||||
ensure!(bytes.len() == total_len, "incorrect section payload length");
|
||||
let payload = &bytes[HEADER_LEN..];
|
||||
ensure!(
|
||||
read_u32(&bytes[12..16]) == crc32(&bytes[..12], payload),
|
||||
"section checksum mismatch"
|
||||
);
|
||||
|
||||
let bits = bytes[6];
|
||||
match bytes[5] {
|
||||
UNIFORM => {
|
||||
ensure!(bits == 0, "invalid uniform section bit width");
|
||||
ensure!(payload.len() == 4, "invalid uniform section length");
|
||||
let id = read_u32(payload);
|
||||
validate_id(id, registry_len)?;
|
||||
Ok(Box::new([id; CELL_COUNT]))
|
||||
}
|
||||
PALETTE => {
|
||||
ensure!(payload.len() >= 2, "truncated section palette length");
|
||||
let count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||
ensure!(
|
||||
(2..=CELL_COUNT).contains(&count),
|
||||
"invalid section palette length"
|
||||
);
|
||||
ensure!(
|
||||
bits == palette_bits(count),
|
||||
"invalid section palette bit width"
|
||||
);
|
||||
let packed_start = 2 + count * 4;
|
||||
ensure!(
|
||||
payload.len() == packed_start + packed_len(bits),
|
||||
"incorrect palette section payload length"
|
||||
);
|
||||
|
||||
let mut palette = Vec::with_capacity(count);
|
||||
let mut unique = HashSet::with_capacity(count);
|
||||
for entry in payload[2..packed_start].chunks_exact(4) {
|
||||
let id = read_u32(entry);
|
||||
validate_id(id, registry_len)?;
|
||||
ensure!(unique.insert(id), "duplicate block ID in section palette");
|
||||
palette.push(id);
|
||||
}
|
||||
let packed = &payload[packed_start..];
|
||||
let mut cells = Box::new([0; CELL_COUNT]);
|
||||
for (cell, id) in cells.iter_mut().enumerate() {
|
||||
let index = read_index(packed, cell, bits);
|
||||
ensure!(index < count, "section palette index out of range");
|
||||
*id = palette[index];
|
||||
}
|
||||
Ok(cells)
|
||||
}
|
||||
DENSE => {
|
||||
ensure!(bits == 32, "invalid dense section bit width");
|
||||
ensure!(
|
||||
payload.len() == DENSE_PAYLOAD_LEN,
|
||||
"invalid dense section length"
|
||||
);
|
||||
let mut cells = Box::new([0; CELL_COUNT]);
|
||||
for (cell, entry) in cells.iter_mut().zip(payload.chunks_exact(4)) {
|
||||
let id = read_u32(entry);
|
||||
validate_id(id, registry_len)?;
|
||||
*cell = id;
|
||||
}
|
||||
Ok(cells)
|
||||
}
|
||||
mode => bail!("unsupported section mode {mode}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one block directly from a validated section without allocation.
|
||||
///
|
||||
/// `bytes` must have passed `decode` and remained unchanged, or have been
|
||||
/// produced by `encode`. This intentionally skips validation and checksum
|
||||
/// computation so a compact cached section supports constant-time reads.
|
||||
pub(crate) fn read_block(bytes: &[u8], index: usize) -> u32 {
|
||||
assert!(index < CELL_COUNT, "section cell index out of range");
|
||||
let payload = &bytes[HEADER_LEN..];
|
||||
match bytes[5] {
|
||||
UNIFORM => read_u32(payload),
|
||||
PALETTE => {
|
||||
let count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||
let packed_start = 2 + count * 4;
|
||||
let palette_index = read_index(&payload[packed_start..], index, bytes[6]);
|
||||
read_u32(&payload[2 + palette_index * 4..])
|
||||
}
|
||||
DENSE => read_u32(&payload[index * 4..]),
|
||||
mode => unreachable!("validated section has unsupported mode {mode}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_id(id: u32, registry_len: usize) -> Result<()> {
|
||||
ensure!(
|
||||
(id as usize) < registry_len,
|
||||
"section block ID {id} is outside the registry"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn palette_bits(count: usize) -> u8 {
|
||||
(u32::BITS - ((count - 1) as u32).leading_zeros()) as u8
|
||||
}
|
||||
|
||||
fn packed_len(bits: u8) -> usize {
|
||||
// 4096 is divisible by eight, so there are no unused trailing bits.
|
||||
CELL_COUNT / 8 * bits as usize
|
||||
}
|
||||
|
||||
fn write_index(packed: &mut [u8], cell: usize, bits: u8, index: u16) {
|
||||
let offset = cell * bits as usize;
|
||||
let byte = offset / 8;
|
||||
let shift = offset % 8;
|
||||
let value = (index as u32) << shift;
|
||||
packed[byte] |= value as u8;
|
||||
if shift + bits as usize > 8 {
|
||||
packed[byte + 1] |= (value >> 8) as u8;
|
||||
}
|
||||
if shift + bits as usize > 16 {
|
||||
packed[byte + 2] |= (value >> 16) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_index(packed: &[u8], cell: usize, bits: u8) -> usize {
|
||||
let offset = cell * bits as usize;
|
||||
let byte = offset / 8;
|
||||
let shift = offset % 8;
|
||||
let mut value = packed[byte] as u32;
|
||||
if shift + bits as usize > 8 {
|
||||
value |= (packed[byte + 1] as u32) << 8;
|
||||
}
|
||||
if shift + bits as usize > 16 {
|
||||
value |= (packed[byte + 2] as u32) << 16;
|
||||
}
|
||||
((value >> shift) & ((1 << bits) - 1)) as usize
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8]) -> u32 {
|
||||
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
}
|
||||
|
||||
fn envelope(mode: u8, bits: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(HEADER_LEN + payload.len());
|
||||
bytes.extend_from_slice(MAGIC);
|
||||
bytes.extend_from_slice(&[VERSION, mode, bits, 0]);
|
||||
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
bytes.extend_from_slice(payload);
|
||||
let checksum = crc32(&bytes[..12], payload);
|
||||
bytes[12..16].copy_from_slice(&checksum.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
const CRC32_TABLE: [u32; 256] = {
|
||||
let mut table = [0; 256];
|
||||
let mut index = 0;
|
||||
while index < 256 {
|
||||
let mut value = index as u32;
|
||||
let mut bit = 0;
|
||||
while bit < 8 {
|
||||
value = if value & 1 != 0 {
|
||||
(value >> 1) ^ 0xedb8_8320
|
||||
} else {
|
||||
value >> 1
|
||||
};
|
||||
bit += 1;
|
||||
}
|
||||
table[index] = value;
|
||||
index += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
fn crc32(header: &[u8], payload: &[u8]) -> u32 {
|
||||
let mut crc = u32::MAX;
|
||||
for &byte in header.iter().chain(payload) {
|
||||
crc = (crc >> 8) ^ CRC32_TABLE[((crc as u8) ^ byte) as usize];
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn varied_cells(count: usize) -> Cells {
|
||||
std::array::from_fn(|cell| (cell % count) as u32)
|
||||
}
|
||||
|
||||
fn repair_checksum(bytes: &mut [u8]) {
|
||||
let checksum = crc32(&bytes[..12], &bytes[HEADER_LEN..]);
|
||||
bytes[12..16].copy_from_slice(&checksum.to_le_bytes());
|
||||
}
|
||||
|
||||
fn assert_direct_reads(bytes: &[u8], cells: &Cells) {
|
||||
for (index, &id) in cells.iter().enumerate() {
|
||||
assert_eq!(read_block(bytes, index), id, "cell {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_standard_check_value() {
|
||||
assert_eq!(crc32(b"1234", b"56789"), 0xcbf4_3926);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_round_trip_and_little_endian_layout() {
|
||||
let cells = [0x01_02_03_04; CELL_COUNT];
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes.len(), 20);
|
||||
assert_eq!(&bytes[..12], b"SHSC\x01\x00\x00\x00\x04\x00\x00\x00");
|
||||
assert_eq!(&bytes[HEADER_LEN..], &[4, 3, 2, 1]);
|
||||
assert_eq!(*decode(&bytes, 0x01_02_03_05).unwrap(), cells);
|
||||
assert_direct_reads(&bytes, &cells);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_palette_width_round_trips() {
|
||||
for bits in 1..=12 {
|
||||
let count = (1usize << (bits - 1)) + 1;
|
||||
let cells = varied_cells(count);
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes[5], PALETTE, "count {count}");
|
||||
assert_eq!(bytes[6], bits);
|
||||
assert_eq!(bytes.len(), HEADER_LEN + 2 + count * 4 + packed_len(bits));
|
||||
assert_eq!(*decode(&bytes, count).unwrap(), cells);
|
||||
assert_eq!(bytes, encode(&cells), "encoding is deterministic");
|
||||
assert_direct_reads(&bytes, &cells);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_width_changes_at_powers_of_two() {
|
||||
for bits in 1..=11 {
|
||||
let count = 1usize << bits;
|
||||
let cells = varied_cells(count);
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes[6], bits);
|
||||
assert_eq!(*decode(&bytes, count).unwrap(), cells);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_round_trip_and_size_crossover() {
|
||||
for count in [2560, CELL_COUNT] {
|
||||
let cells = varied_cells(count);
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes[5], DENSE);
|
||||
assert_eq!(bytes[6], 32);
|
||||
assert_eq!(bytes.len(), HEADER_LEN + DENSE_PAYLOAD_LEN);
|
||||
assert_eq!(*decode(&bytes, count).unwrap(), cells);
|
||||
assert_direct_reads(&bytes, &cells);
|
||||
}
|
||||
assert_eq!(encode(&varied_cells(2559))[5], PALETTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "section cell index out of range")]
|
||||
fn direct_read_rejects_out_of_range_cell() {
|
||||
read_block(&encode(&[0; CELL_COUNT]), CELL_COUNT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_indices_match_cross_byte_wire_fixture() {
|
||||
let mut packed = [0; 3];
|
||||
for index in 0..8 {
|
||||
write_index(&mut packed, index, 3, index as u16);
|
||||
}
|
||||
assert_eq!(packed, [0x88, 0xc6, 0xfa]);
|
||||
for index in 0..8 {
|
||||
assert_eq!(read_index(&packed, index, 3), index);
|
||||
}
|
||||
|
||||
// Eleven-bit indices can span three bytes at some bit offsets.
|
||||
let mut packed = [0; 11];
|
||||
let indices = [0x7ff, 0x401, 0x255, 0x623, 0x456, 0x123, 0x321, 0x700];
|
||||
for (cell, &index) in indices.iter().enumerate() {
|
||||
write_index(&mut packed, cell, 11, index);
|
||||
}
|
||||
for (cell, &index) in indices.iter().enumerate() {
|
||||
assert_eq!(read_index(&packed, cell, 11), index as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_extra_and_corrupted_bytes_are_rejected() {
|
||||
for cells in [[0; CELL_COUNT], varied_cells(3), varied_cells(CELL_COUNT)] {
|
||||
let bytes = encode(&cells);
|
||||
for length in 0..bytes.len() {
|
||||
assert!(decode(&bytes[..length], CELL_COUNT).is_err());
|
||||
}
|
||||
let mut extra = bytes.clone();
|
||||
extra.push(0);
|
||||
assert!(decode(&extra, CELL_COUNT).is_err());
|
||||
for offset in [0, 4, 5, 6, 7, 8, 12, HEADER_LEN, bytes.len() - 1] {
|
||||
let mut corrupted = bytes.clone();
|
||||
corrupted[offset] ^= 1;
|
||||
assert!(decode(&corrupted, CELL_COUNT).is_err(), "offset {offset}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structurally_invalid_headers_are_rejected_even_with_valid_checksum() {
|
||||
for (offset, value) in [(0, b'X'), (4, 2), (5, 3), (6, 1), (7, 1)] {
|
||||
let mut bytes = encode(&[0; CELL_COUNT]);
|
||||
bytes[offset] = value;
|
||||
repair_checksum(&mut bytes);
|
||||
assert!(decode(&bytes, 1).is_err());
|
||||
}
|
||||
assert!(decode(&envelope(UNIFORM, 0, &[0; 3]), 1).is_err());
|
||||
assert!(decode(&envelope(DENSE, 32, &[0; 4]), 1).is_err());
|
||||
assert!(decode(&envelope(DENSE, 0, &[0; DENSE_PAYLOAD_LEN]), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_palettes_and_indices_are_rejected() {
|
||||
for count in [0u16, 1, 4097, u16::MAX] {
|
||||
assert!(decode(&envelope(PALETTE, 1, &count.to_le_bytes()), 10).is_err());
|
||||
}
|
||||
assert!(decode(&envelope(PALETTE, 1, &[]), 10).is_err());
|
||||
let original = encode(&varied_cells(3));
|
||||
|
||||
let mut duplicate = original.clone();
|
||||
duplicate[HEADER_LEN + 6..HEADER_LEN + 10].copy_from_slice(&0u32.to_le_bytes());
|
||||
repair_checksum(&mut duplicate);
|
||||
assert!(decode(&duplicate, 3).is_err());
|
||||
|
||||
let mut bad_width = original.clone();
|
||||
bad_width[6] = 3;
|
||||
repair_checksum(&mut bad_width);
|
||||
assert!(decode(&bad_width, 3).is_err());
|
||||
|
||||
let mut bad_index = original.clone();
|
||||
bad_index[HEADER_LEN + 2 + 3 * 4] |= 3;
|
||||
repair_checksum(&mut bad_index);
|
||||
assert!(decode(&bad_index, 3).is_err());
|
||||
|
||||
let mut short_payload = original[HEADER_LEN..].to_vec();
|
||||
short_payload.pop();
|
||||
assert!(decode(&envelope(PALETTE, 2, &short_payload), 3).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_storage_mode_checks_registry_bounds() {
|
||||
assert!(decode(&encode(&[0; CELL_COUNT]), 0).is_err());
|
||||
assert!(decode(&encode(&[1; CELL_COUNT]), 1).is_err());
|
||||
assert!(decode(&encode(&varied_cells(3)), 2).is_err());
|
||||
assert!(decode(&encode(&varied_cells(CELL_COUNT)), CELL_COUNT - 1).is_err());
|
||||
|
||||
// An unused palette entry must still refer to a known block ID.
|
||||
let mut bytes = encode(&varied_cells(3));
|
||||
bytes[HEADER_LEN + 2 + 3 * 4..].fill(0);
|
||||
repair_checksum(&mut bytes);
|
||||
assert!(decode(&bytes, 2).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
use crate::cache::SectionCache;
|
||||
use crate::database::{self, SQLITE_CACHE_KIB};
|
||||
use crate::section::{self, CELL_COUNT, Cells};
|
||||
use crate::{
|
||||
BlockChange, BlockId, COORD_LIMIT, EditResult, MAX_EDIT_CELLS, MAX_OPERATION_ID_BYTES,
|
||||
MAX_READ_VOLUME, MAX_REGISTRY_STATES, MAX_STATE_BYTES, MAX_WORLDS, Pos, WorldInfo,
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use fs2::FileExt;
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
type SectionPos = [i32; 3];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct World {
|
||||
id: i64,
|
||||
revision: i64,
|
||||
epoch: i64,
|
||||
base: Option<i64>,
|
||||
}
|
||||
|
||||
/// Single-writer, disk-indexed world store. The lock is held until drop.
|
||||
pub struct WorldStore {
|
||||
conn: Connection,
|
||||
root: PathBuf,
|
||||
registry: Vec<String>,
|
||||
cache: SectionCache,
|
||||
commit_count: u64,
|
||||
last_commit_us: u64,
|
||||
transient_section_peak_bytes: usize,
|
||||
// Declare last: connection closes before the store's advisory lock releases.
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
impl WorldStore {
|
||||
pub fn open(path: impl AsRef<Path>, cache_sections: usize) -> Result<Self> {
|
||||
ensure!(
|
||||
cache_sections > 0,
|
||||
"input_limit: cache_sections must be positive"
|
||||
);
|
||||
fs::create_dir_all(path.as_ref()).context("storage_open: create directory")?;
|
||||
let root = fs::canonicalize(path.as_ref()).context("storage_open: resolve directory")?;
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(root.join("store.lock"))
|
||||
.context("storage_open: open lock file")?;
|
||||
lock.try_lock_exclusive()
|
||||
.context("storage_locked: another WorldStore owns this directory")?;
|
||||
let mut conn = Connection::open(root.join("worlds.sqlite3"))
|
||||
.context("storage_open: open SQLite database")?;
|
||||
database::configure(&mut conn).context("storage_open: initialize/recover database")?;
|
||||
let count: i64 = conn.query_row("SELECT count(*) FROM registry", [], |r| r.get(0))?;
|
||||
ensure!(
|
||||
count > 0 && count <= MAX_REGISTRY_STATES as i64,
|
||||
"storage_corrupt: registry cardinality"
|
||||
);
|
||||
let max_state_length: i64 = conn.query_row(
|
||||
"SELECT COALESCE(max(length(CAST(state AS BLOB))),0) FROM registry",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
max_state_length <= MAX_STATE_BYTES as i64,
|
||||
"storage_corrupt: registry state length"
|
||||
);
|
||||
let mut registry = Vec::with_capacity(count as usize);
|
||||
{
|
||||
let mut stmt = conn.prepare("SELECT id,state FROM registry ORDER BY id")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let state: String = row.get(1)?;
|
||||
ensure!(
|
||||
id == registry.len() as i64,
|
||||
"storage_corrupt: noncontiguous registry IDs"
|
||||
);
|
||||
ensure!(
|
||||
canonical_state(&state)? == state,
|
||||
"storage_corrupt: noncanonical registry state"
|
||||
);
|
||||
registry.push(state);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
registry[0] == "minecraft:air",
|
||||
"storage_corrupt: air ID 0 was reassigned"
|
||||
);
|
||||
// Persist directory entries for a newly created database and store directory.
|
||||
File::open(&root)?.sync_all()?;
|
||||
if let Some(parent) = root.parent() {
|
||||
File::open(parent)?.sync_all()?;
|
||||
}
|
||||
Ok(Self {
|
||||
conn,
|
||||
root,
|
||||
registry,
|
||||
cache: SectionCache::new(cache_sections),
|
||||
commit_count: 0,
|
||||
last_commit_us: 0,
|
||||
transient_section_peak_bytes: 0,
|
||||
_lock: lock,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_worlds(&self) -> Result<Vec<WorldInfo>> {
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT name,revision,template_name FROM worlds ORDER BY name")?;
|
||||
let worlds = statement.query_map([], |row| {
|
||||
Ok(WorldInfo {
|
||||
name: row.get(0)?,
|
||||
revision: row.get::<_, i64>(1)? as u64,
|
||||
template: row.get(2)?,
|
||||
})
|
||||
})?;
|
||||
worlds.collect::<rusqlite::Result<_>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn create_world(&mut self, name: &str, template: Option<&str>) -> Result<()> {
|
||||
validate_world_name(name)?;
|
||||
if let Some(source) = template {
|
||||
validate_world_name(source)?;
|
||||
}
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let count: i64 = tx.query_row("SELECT count(*) FROM worlds", [], |r| r.get(0))?;
|
||||
ensure!(
|
||||
count < MAX_WORLDS as i64,
|
||||
"input_limit: maximum world count reached"
|
||||
);
|
||||
let exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM worlds WHERE name=?1)",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
ensure!(!exists, "world_exists: {name}");
|
||||
let snapshot = if let Some(source) = template {
|
||||
let world = lookup_world(&tx, source)?;
|
||||
let existing: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM snapshots WHERE source_world=?1 AND source_revision=?2",
|
||||
params![world.id, world.revision],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Some(if let Some(id) = existing {
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO snapshots(source_world,source_revision) VALUES(?1,?2)",
|
||||
params![world.id, world.revision],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
if let Some(base) = world.base {
|
||||
tx.execute(
|
||||
"INSERT INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
|
||||
SELECT ?1,x,y,z,blob_id FROM snapshot_sections WHERE snapshot_id=?2",
|
||||
params![id, base],
|
||||
)?;
|
||||
}
|
||||
// Indexed SQL copies references; no world-sized collection in Rust.
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
|
||||
SELECT ?1,x,y,z,blob_id FROM world_sections WHERE world_id=?2",
|
||||
params![id, world.id],
|
||||
)?;
|
||||
id
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO worlds(name,base_snapshot,template_name) VALUES(?1,?2,?3)",
|
||||
params![name, snapshot, template],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: create_world")?;
|
||||
self.record_commit(started);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_block(&mut self, state: &str) -> Result<BlockId> {
|
||||
let state = canonical_state(state)?;
|
||||
let existing: Option<u32> = self
|
||||
.conn
|
||||
.query_row("SELECT id FROM registry WHERE state=?1", [&state], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
ensure!(
|
||||
self.registry.len() < MAX_REGISTRY_STATES,
|
||||
"input_limit: registry is full"
|
||||
);
|
||||
let id = self.registry.len() as BlockId;
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute(
|
||||
"INSERT INTO registry(id,state) VALUES(?1,?2)",
|
||||
params![id, state],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: register_block")?;
|
||||
self.registry.push(state);
|
||||
self.record_commit(started);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &[String] {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn revision(&self, world: &str) -> Result<u64> {
|
||||
validate_world_name(world)?;
|
||||
Ok(lookup_world(&self.conn, world)?.revision as u64)
|
||||
}
|
||||
|
||||
pub fn get_block(&mut self, name: &str, pos: Pos) -> Result<BlockId> {
|
||||
validate_world_name(name)?;
|
||||
validate_pos(pos)?;
|
||||
let world = lookup_world(&self.conn, name)?;
|
||||
let (section, index) = locate(pos);
|
||||
let result = match effective_blob(&self.conn, world, section)? {
|
||||
Some(id) => Ok(section::read_block(
|
||||
load_encoded(&self.conn, &mut self.cache, self.registry.len(), id)?,
|
||||
index,
|
||||
)),
|
||||
None => Ok(0),
|
||||
};
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(CELL_COUNT * size_of::<u32>());
|
||||
result
|
||||
}
|
||||
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expected_revision: u64,
|
||||
operation_id: &str,
|
||||
mut changes: Vec<BlockChange>,
|
||||
) -> Result<EditResult> {
|
||||
validate_world_name(name)?;
|
||||
validate_operation_id(operation_id)?;
|
||||
validate_revision(expected_revision)?;
|
||||
ensure!(
|
||||
changes.len() <= MAX_EDIT_CELLS,
|
||||
"input_limit: edit exceeds {MAX_EDIT_CELLS} cells"
|
||||
);
|
||||
for change in &changes {
|
||||
validate_pos(change.pos)?;
|
||||
ensure!(
|
||||
(change.block as usize) < self.registry.len(),
|
||||
"unknown_block: {}",
|
||||
change.block
|
||||
);
|
||||
}
|
||||
changes.sort_unstable_by_key(|change| change.pos);
|
||||
ensure!(
|
||||
changes.windows(2).all(|pair| pair[0].pos != pair[1].pos),
|
||||
"duplicate_position: edit contains the same cell twice"
|
||||
);
|
||||
let fingerprint = edit_fingerprint(expected_revision, &changes);
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let world = lookup_world(&tx, name)?;
|
||||
if let Some(result) = replay(&tx, world.id, operation_id, &fingerprint)? {
|
||||
return Ok(result);
|
||||
}
|
||||
check_revision(world, expected_revision)?;
|
||||
let next = next_revision(world.revision)?;
|
||||
let op = insert_operation(&tx, world, operation_id, "edit", &fingerprint, next, 0)?;
|
||||
let changed = apply_changes(
|
||||
&tx,
|
||||
&mut self.cache,
|
||||
self.registry.len(),
|
||||
world,
|
||||
op,
|
||||
&changes,
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE operations SET changed=?1 WHERE id=?2",
|
||||
params![changed as i64, op],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE worlds SET revision=?1 WHERE id=?2",
|
||||
params![next, world.id],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: edit")?;
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(2 * CELL_COUNT * size_of::<u32>());
|
||||
self.record_commit(started);
|
||||
Ok(EditResult {
|
||||
revision: next as u64,
|
||||
changed,
|
||||
replayed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn undo(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expected_revision: u64,
|
||||
operation_id: &str,
|
||||
target_operation: &str,
|
||||
) -> Result<EditResult> {
|
||||
validate_world_name(name)?;
|
||||
validate_operation_id(operation_id)?;
|
||||
validate_operation_id(target_operation)?;
|
||||
validate_revision(expected_revision)?;
|
||||
let fingerprint = named_fingerprint("undo", expected_revision, target_operation);
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let world = lookup_world(&tx, name)?;
|
||||
if let Some(result) = replay(&tx, world.id, operation_id, &fingerprint)? {
|
||||
return Ok(result);
|
||||
}
|
||||
check_revision(world, expected_revision)?;
|
||||
let target: Option<(i64, String, i64, i64)> = tx.query_row(
|
||||
"SELECT id,kind,revision,epoch FROM operations WHERE world_id=?1 AND operation_id=?2",
|
||||
params![world.id, target_operation],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
).optional()?;
|
||||
let (target_id, kind, revision, epoch) =
|
||||
target.context("unknown_operation: undo target does not exist in this world")?;
|
||||
ensure!(kind == "edit", "undo_unsupported: only edits can be undone");
|
||||
ensure!(
|
||||
epoch == world.epoch && revision == world.revision,
|
||||
"undo_conflict: target must be the last edit revision and precede no reset"
|
||||
);
|
||||
let changes = {
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT x,y,z,before_id FROM operation_changes WHERE operation=?1 ORDER BY x,y,z LIMIT ?2",
|
||||
)?;
|
||||
let rows =
|
||||
statement.query_map(params![target_id, (MAX_EDIT_CELLS + 1) as i64], |row| {
|
||||
Ok(BlockChange {
|
||||
pos: [row.get(0)?, row.get(1)?, row.get(2)?],
|
||||
block: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
ensure!(
|
||||
changes.len() <= MAX_EDIT_CELLS,
|
||||
"storage_corrupt: undo history exceeds operation bound"
|
||||
);
|
||||
for change in &changes {
|
||||
validate_pos(change.pos).context("storage_corrupt: undo position")?;
|
||||
ensure!(
|
||||
(change.block as usize) < self.registry.len(),
|
||||
"storage_corrupt: undo block ID"
|
||||
);
|
||||
}
|
||||
let next = next_revision(world.revision)?;
|
||||
let op = insert_operation(&tx, world, operation_id, "undo", &fingerprint, next, 0)?;
|
||||
let changed = apply_changes(
|
||||
&tx,
|
||||
&mut self.cache,
|
||||
self.registry.len(),
|
||||
world,
|
||||
op,
|
||||
&changes,
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE operations SET changed=?1 WHERE id=?2",
|
||||
params![changed as i64, op],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE worlds SET revision=?1 WHERE id=?2",
|
||||
params![next, world.id],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: undo")?;
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(2 * CELL_COUNT * size_of::<u32>());
|
||||
self.record_commit(started);
|
||||
Ok(EditResult {
|
||||
revision: next as u64,
|
||||
changed,
|
||||
replayed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reset_world(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expected_revision: u64,
|
||||
operation_id: &str,
|
||||
) -> Result<EditResult> {
|
||||
validate_world_name(name)?;
|
||||
validate_operation_id(operation_id)?;
|
||||
validate_revision(expected_revision)?;
|
||||
let fingerprint = named_fingerprint("reset", expected_revision, "");
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let world = lookup_world(&tx, name)?;
|
||||
if let Some(result) = replay(&tx, world.id, operation_id, &fingerprint)? {
|
||||
return Ok(result);
|
||||
}
|
||||
check_revision(world, expected_revision)?;
|
||||
let next = next_revision(world.revision)?;
|
||||
let epoch = next_revision(world.epoch)?;
|
||||
let changed: i64 = tx.query_row(
|
||||
"SELECT COALESCE(sum(changed_cells),0) FROM world_sections WHERE world_id=?1",
|
||||
[world.id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let changed = usize::try_from(changed).context("storage_corrupt: reset change count")?;
|
||||
insert_operation(
|
||||
&tx,
|
||||
World { epoch, ..world },
|
||||
operation_id,
|
||||
"reset",
|
||||
&fingerprint,
|
||||
next,
|
||||
changed,
|
||||
)?;
|
||||
tx.execute("DELETE FROM world_sections WHERE world_id=?1", [world.id])?;
|
||||
tx.execute(
|
||||
"UPDATE worlds SET revision=?1,epoch=?2 WHERE id=?3",
|
||||
params![next, epoch, world.id],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: reset_world")?;
|
||||
self.record_commit(started);
|
||||
Ok(EditResult {
|
||||
revision: next as u64,
|
||||
changed,
|
||||
replayed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_region(&mut self, name: &str, min: Pos, max: Pos) -> Result<Vec<BlockChange>> {
|
||||
validate_world_name(name)?;
|
||||
validate_pos(min)?;
|
||||
validate_pos(max)?;
|
||||
let mut volume = 1_u64;
|
||||
for axis in 0..3 {
|
||||
ensure!(min[axis] <= max[axis], "invalid_region: min exceeds max");
|
||||
let size = (i64::from(max[axis]) - i64::from(min[axis]) + 1) as u64;
|
||||
volume = volume
|
||||
.checked_mul(size)
|
||||
.context("input_limit: region volume overflow")?;
|
||||
ensure!(
|
||||
volume <= MAX_READ_VOLUME as u64,
|
||||
"input_limit: region exceeds {MAX_READ_VOLUME} cells"
|
||||
);
|
||||
}
|
||||
let world = lookup_world(&self.conn, name)?;
|
||||
let (lower, _) = locate(min);
|
||||
let (upper, _) = locate(max);
|
||||
let mut result = Vec::new();
|
||||
for sy in lower[1]..=upper[1] {
|
||||
for sz in lower[2]..=upper[2] {
|
||||
for sx in lower[0]..=upper[0] {
|
||||
let section = [sx, sy, sz];
|
||||
let Some(blob) = effective_blob(&self.conn, world, section)? else {
|
||||
continue;
|
||||
};
|
||||
let cells =
|
||||
load_decoded(&self.conn, &mut self.cache, self.registry.len(), blob)?;
|
||||
let start = [
|
||||
min[0].max(sx * 16),
|
||||
min[1].max(sy * 16),
|
||||
min[2].max(sz * 16),
|
||||
];
|
||||
let end = [
|
||||
max[0].min(sx * 16 + 15),
|
||||
max[1].min(sy * 16 + 15),
|
||||
max[2].min(sz * 16 + 15),
|
||||
];
|
||||
for y in start[1]..=end[1] {
|
||||
for z in start[2]..=end[2] {
|
||||
for x in start[0]..=end[0] {
|
||||
let pos = [x, y, z];
|
||||
let block = cells[locate(pos).1];
|
||||
if block != 0 {
|
||||
result.push(BlockChange { pos, block });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(CELL_COUNT * size_of::<u32>());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Durably committed operations need no flush to survive process restart.
|
||||
/// This also checkpoints WAL, reporting busy/error instead of hiding it.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
let (busy, log, checkpointed): (i64, i64, i64) =
|
||||
self.conn
|
||||
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})?;
|
||||
ensure!(
|
||||
busy == 0 && (log == checkpointed || log == -1),
|
||||
"storage_busy: WAL checkpoint incomplete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete at most `max_blobs` unreachable immutable blobs. History is kept.
|
||||
/// This never runs automatically; a caller can bound maintenance work.
|
||||
pub fn collect_garbage(&mut self, max_blobs: usize) -> Result<usize> {
|
||||
ensure!(
|
||||
(1..=4096).contains(&max_blobs),
|
||||
"input_limit: GC batch must be 1..4096"
|
||||
);
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let removed = tx.execute(
|
||||
"DELETE FROM blobs WHERE id IN (
|
||||
SELECT b.id FROM blobs b
|
||||
WHERE NOT EXISTS(SELECT 1 FROM world_sections w WHERE w.blob_id=b.id)
|
||||
AND NOT EXISTS(SELECT 1 FROM snapshot_sections s WHERE s.blob_id=b.id)
|
||||
LIMIT ?1)",
|
||||
[max_blobs as i64],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: collect_garbage")?;
|
||||
// SQLite may reuse deleted rowids; no stale cached ID may survive GC.
|
||||
self.cache.clear();
|
||||
self.record_commit(started);
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Metrics schema v1. Payload estimates are not allocator usage or OS RSS.
|
||||
/// Persistent counts survive restart; cache/latency counters are per handle.
|
||||
/// File sizes include allocated SQLite pages, not only live logical records.
|
||||
pub fn stats(&self) -> Value {
|
||||
let count = |table: &str| -> Option<i64> {
|
||||
// Table names below are fixed program constants, never user inputs.
|
||||
self.conn
|
||||
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||
.ok()
|
||||
};
|
||||
let file_size = |name: &str| -> Option<u64> {
|
||||
match fs::metadata(self.root.join(name)) {
|
||||
Ok(meta) => Some(meta.len()),
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound && name != "worlds.sqlite3" =>
|
||||
{
|
||||
Some(0)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
json!({
|
||||
"schema_version": 1,
|
||||
"cache_entries": self.cache.len(),
|
||||
"cache_capacity_sections": self.cache.capacity,
|
||||
"cache_payload_bytes": self.cache.payload_bytes(),
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"cache_hits": self.cache.hits,
|
||||
"cache_misses": self.cache.misses,
|
||||
"cache_evictions": self.cache.evictions,
|
||||
"sqlite_cache_budget_bytes": SQLITE_CACHE_KIB * 1024,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"registry_count": self.registry.len(),
|
||||
"registry_limit_states": MAX_REGISTRY_STATES,
|
||||
"registry_text_bytes": self.registry.iter().map(String::len).sum::<usize>(),
|
||||
"world_count": count("worlds"),
|
||||
"world_limit": MAX_WORLDS,
|
||||
"snapshot_count": count("snapshots"),
|
||||
"immutable_blob_count": count("blobs"),
|
||||
"overlay_section_count": count("world_sections"),
|
||||
"operation_count": count("operations"),
|
||||
"history_change_count": count("operation_changes"),
|
||||
"database_bytes": file_size("worlds.sqlite3"),
|
||||
"wal_bytes": file_size("worlds.sqlite3-wal"),
|
||||
"shm_bytes": file_size("worlds.sqlite3-shm"),
|
||||
"commits_this_handle": self.commit_count,
|
||||
"last_mutation_duration_us": self.last_commit_us,
|
||||
"transient_decoded_payload_peak_estimate_bytes": self.transient_section_peak_bytes,
|
||||
"os_rss_bytes": Value::Null,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"max_edit_cells": MAX_EDIT_CELLS,
|
||||
"max_read_volume": MAX_READ_VOLUME,
|
||||
"coordinate_limit": COORD_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
fn record_commit(&mut self, started: Instant) {
|
||||
self.commit_count = self.commit_count.saturating_add(1);
|
||||
self.last_commit_us = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_world_name(name: &str) -> Result<()> {
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'),
|
||||
"invalid_world_name: expected [A-Za-z0-9_-]{{1,64}}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_operation_id(id: &str) -> Result<()> {
|
||||
ensure!(
|
||||
!id.is_empty() && id.len() <= MAX_OPERATION_ID_BYTES && !id.chars().any(char::is_control),
|
||||
"invalid_operation_id: expected 1..{MAX_OPERATION_ID_BYTES} UTF-8 bytes without control characters"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_revision(revision: u64) -> Result<()> {
|
||||
ensure!(
|
||||
revision <= i64::MAX as u64,
|
||||
"revision_overflow: maximum is i64::MAX"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_pos(pos: Pos) -> Result<()> {
|
||||
ensure!(
|
||||
pos.into_iter()
|
||||
.all(|v| (-COORD_LIMIT..=COORD_LIMIT).contains(&v)),
|
||||
"invalid_position: coordinates must be within +/-{COORD_LIMIT}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_state(state: &str) -> Result<String> {
|
||||
ensure!(
|
||||
!state.is_empty() && state.len() <= MAX_STATE_BYTES && state.is_ascii(),
|
||||
"invalid_block_state: size or encoding"
|
||||
);
|
||||
let (identifier, properties) = if let Some(open) = state.find('[') {
|
||||
ensure!(
|
||||
state.ends_with(']'),
|
||||
"invalid_block_state: missing closing bracket"
|
||||
);
|
||||
(&state[..open], Some(&state[open + 1..state.len() - 1]))
|
||||
} else {
|
||||
(state, None)
|
||||
};
|
||||
let (namespace, path) = identifier
|
||||
.split_once(':')
|
||||
.context("invalid_block_state: namespace required")?;
|
||||
let valid_part = |part: &str, allow_slash: bool| {
|
||||
!part.is_empty()
|
||||
&& part.bytes().all(|b| {
|
||||
b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| b"_.-".contains(&b)
|
||||
|| (allow_slash && b == b'/')
|
||||
})
|
||||
};
|
||||
ensure!(
|
||||
valid_part(namespace, false) && valid_part(path, true),
|
||||
"invalid_block_state: malformed identifier"
|
||||
);
|
||||
if let Some(properties) = properties {
|
||||
ensure!(
|
||||
!properties.is_empty(),
|
||||
"invalid_block_state: empty property list"
|
||||
);
|
||||
let mut sorted = BTreeMap::new();
|
||||
for property in properties.split(',') {
|
||||
let (key, value) = property
|
||||
.split_once('=')
|
||||
.context("invalid_block_state: malformed property")?;
|
||||
ensure!(
|
||||
valid_part(key, false) && valid_part(value, true),
|
||||
"invalid_block_state: malformed property"
|
||||
);
|
||||
ensure!(
|
||||
sorted.insert(key, value).is_none(),
|
||||
"invalid_block_state: duplicate property"
|
||||
);
|
||||
}
|
||||
let body = sorted
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Ok(format!("{identifier}[{body}]"))
|
||||
} else {
|
||||
Ok(identifier.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn locate(pos: Pos) -> (SectionPos, usize) {
|
||||
let section = pos.map(|v| v.div_euclid(16));
|
||||
let [x, y, z] = pos.map(|v| v.rem_euclid(16) as usize);
|
||||
(section, x + 16 * z + 256 * y)
|
||||
}
|
||||
|
||||
fn lookup_world(conn: &Connection, name: &str) -> Result<World> {
|
||||
conn.query_row(
|
||||
"SELECT id,revision,epoch,base_snapshot FROM worlds WHERE name=?1",
|
||||
[name],
|
||||
|r| {
|
||||
Ok(World {
|
||||
id: r.get(0)?,
|
||||
revision: r.get(1)?,
|
||||
epoch: r.get(2)?,
|
||||
base: r.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.with_context(|| format!("unknown_world: {name}"))
|
||||
}
|
||||
|
||||
fn check_revision(world: World, expected: u64) -> Result<()> {
|
||||
ensure!(
|
||||
world.revision as u64 == expected,
|
||||
"revision_conflict: expected {expected}, current {}",
|
||||
world.revision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_revision(current: i64) -> Result<i64> {
|
||||
current
|
||||
.checked_add(1)
|
||||
.context("revision_overflow: maximum is i64::MAX")
|
||||
}
|
||||
|
||||
fn base_blob(conn: &Connection, base: Option<i64>, [x, y, z]: SectionPos) -> Result<Option<i64>> {
|
||||
if let Some(base) = base {
|
||||
Ok(conn.query_row(
|
||||
"SELECT blob_id FROM snapshot_sections WHERE snapshot_id=?1 AND x=?2 AND y=?3 AND z=?4",
|
||||
params![base, x, y, z], |r| r.get(0),
|
||||
).optional()?)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_blob(
|
||||
conn: &Connection,
|
||||
world: World,
|
||||
pos @ [x, y, z]: SectionPos,
|
||||
) -> Result<Option<i64>> {
|
||||
let own = conn
|
||||
.query_row(
|
||||
"SELECT blob_id FROM world_sections WHERE world_id=?1 AND x=?2 AND y=?3 AND z=?4",
|
||||
params![world.id, x, y, z],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
match own {
|
||||
Some(id) => Ok(Some(id)),
|
||||
None => base_blob(conn, world.base, pos),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_encoded<'a>(
|
||||
conn: &Connection,
|
||||
cache: &'a mut SectionCache,
|
||||
registry_len: usize,
|
||||
id: i64,
|
||||
) -> Result<&'a [u8]> {
|
||||
if cache.get(id).is_some() {
|
||||
return Ok(cache.peek(id).expect("cached section exists"));
|
||||
}
|
||||
let (digest, bytes): (Vec<u8>, Vec<u8>) = conn
|
||||
.query_row(
|
||||
"SELECT digest,data FROM blobs WHERE id=?1 AND length(data)<=?2",
|
||||
params![id, section::MAX_ENCODED_BYTES as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.with_context(|| format!("storage_corrupt: missing or oversized section blob {id}"))?;
|
||||
ensure!(
|
||||
digest.as_slice() == Sha256::digest(&bytes).as_slice(),
|
||||
"storage_corrupt: section blob {id} hash mismatch"
|
||||
);
|
||||
section::decode(&bytes, registry_len)
|
||||
.with_context(|| format!("storage_corrupt: section blob {id}"))?;
|
||||
cache.insert(id, bytes);
|
||||
Ok(cache.peek(id).expect("inserted section exists"))
|
||||
}
|
||||
|
||||
fn load_decoded(
|
||||
conn: &Connection,
|
||||
cache: &mut SectionCache,
|
||||
registry_len: usize,
|
||||
id: i64,
|
||||
) -> Result<Box<Cells>> {
|
||||
section::decode(load_encoded(conn, cache, registry_len, id)?, registry_len)
|
||||
}
|
||||
|
||||
fn intern_blob(tx: &Transaction<'_>, cells: &Cells) -> Result<i64> {
|
||||
let bytes = section::encode(cells);
|
||||
let digest = Sha256::digest(&bytes);
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO blobs(digest,data) VALUES(?1,?2)",
|
||||
params![digest.as_slice(), &bytes],
|
||||
)?;
|
||||
let (id, existing): (i64, Vec<u8>) = tx
|
||||
.query_row(
|
||||
"SELECT id,data FROM blobs WHERE digest=?1 AND length(data)<=?2",
|
||||
params![digest.as_slice(), section::MAX_ENCODED_BYTES as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.context("storage_corrupt: missing or oversized interned blob")?;
|
||||
ensure!(
|
||||
existing == bytes,
|
||||
"storage_corrupt: blob digest collision or corrupted content"
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn replay(
|
||||
tx: &Transaction<'_>,
|
||||
world_id: i64,
|
||||
operation_id: &str,
|
||||
fingerprint: &[u8; 32],
|
||||
) -> Result<Option<EditResult>> {
|
||||
let stored: Option<(Vec<u8>, i64, i64)> = tx.query_row(
|
||||
"SELECT fingerprint,revision,changed FROM operations WHERE world_id=?1 AND operation_id=?2",
|
||||
params![world_id, operation_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
).optional()?;
|
||||
if let Some((stored_fingerprint, revision, changed)) = stored {
|
||||
ensure!(
|
||||
stored_fingerprint.as_slice() == fingerprint,
|
||||
"idempotency_conflict: operation_id already used for a different request"
|
||||
);
|
||||
Ok(Some(EditResult {
|
||||
revision: revision as u64,
|
||||
changed: usize::try_from(changed)?,
|
||||
replayed: true,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_operation(
|
||||
tx: &Transaction<'_>,
|
||||
world: World,
|
||||
operation_id: &str,
|
||||
kind: &str,
|
||||
fingerprint: &[u8; 32],
|
||||
revision: i64,
|
||||
changed: usize,
|
||||
) -> Result<i64> {
|
||||
tx.execute(
|
||||
"INSERT INTO operations(world_id,operation_id,kind,fingerprint,revision,epoch,changed) VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![world.id, operation_id, kind, fingerprint.as_slice(), revision, world.epoch, changed as i64],
|
||||
)?;
|
||||
Ok(tx.last_insert_rowid())
|
||||
}
|
||||
|
||||
fn apply_changes(
|
||||
tx: &Transaction<'_>,
|
||||
cache: &mut SectionCache,
|
||||
registry_len: usize,
|
||||
world: World,
|
||||
operation: i64,
|
||||
changes: &[BlockChange],
|
||||
) -> Result<usize> {
|
||||
let mut sections: BTreeMap<SectionPos, Vec<&BlockChange>> = BTreeMap::new();
|
||||
for change in changes {
|
||||
sections
|
||||
.entry(locate(change.pos).0)
|
||||
.or_default()
|
||||
.push(change);
|
||||
}
|
||||
let mut changed = 0;
|
||||
let mut history = tx.prepare_cached(
|
||||
"INSERT INTO operation_changes(operation,x,y,z,before_id,after_id) VALUES(?1,?2,?3,?4,?5,?6)",
|
||||
)?;
|
||||
for (pos @ [x, y, z], updates) in sections {
|
||||
let mut cells = if let Some(id) = effective_blob(tx, world, pos)? {
|
||||
load_decoded(tx, cache, registry_len, id)?
|
||||
} else {
|
||||
Box::new([0; CELL_COUNT])
|
||||
};
|
||||
let mut section_changed = false;
|
||||
for change in updates {
|
||||
let index = locate(change.pos).1;
|
||||
let before = cells[index];
|
||||
if before != change.block {
|
||||
history.execute(params![
|
||||
operation,
|
||||
change.pos[0],
|
||||
change.pos[1],
|
||||
change.pos[2],
|
||||
before,
|
||||
change.block
|
||||
])?;
|
||||
cells[index] = change.block;
|
||||
changed += 1;
|
||||
section_changed = true;
|
||||
}
|
||||
}
|
||||
if !section_changed {
|
||||
continue;
|
||||
}
|
||||
let baseline = if let Some(id) = base_blob(tx, world.base, pos)? {
|
||||
load_decoded(tx, cache, registry_len, id)?
|
||||
} else {
|
||||
Box::new([0; CELL_COUNT])
|
||||
};
|
||||
let difference = cells
|
||||
.iter()
|
||||
.zip(baseline.iter())
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
if difference == 0 {
|
||||
tx.execute(
|
||||
"DELETE FROM world_sections WHERE world_id=?1 AND x=?2 AND y=?3 AND z=?4",
|
||||
params![world.id, x, y, z],
|
||||
)?;
|
||||
} else {
|
||||
let blob = intern_blob(tx, &cells)?;
|
||||
tx.execute(
|
||||
"INSERT INTO world_sections(world_id,x,y,z,blob_id,changed_cells) VALUES(?1,?2,?3,?4,?5,?6)
|
||||
ON CONFLICT(world_id,x,y,z) DO UPDATE SET blob_id=excluded.blob_id,changed_cells=excluded.changed_cells",
|
||||
params![world.id, x, y, z, blob, difference as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn edit_fingerprint(expected_revision: u64, changes: &[BlockChange]) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"shacraft:edit:v1");
|
||||
hash.update(expected_revision.to_le_bytes());
|
||||
for change in changes {
|
||||
for coordinate in change.pos {
|
||||
hash.update(coordinate.to_le_bytes());
|
||||
}
|
||||
hash.update(change.block.to_le_bytes());
|
||||
}
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn named_fingerprint(kind: &str, expected_revision: u64, target: &str) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"shacraft:named:v1\0");
|
||||
hash.update(kind.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(expected_revision.to_le_bytes());
|
||||
hash.update(target.as_bytes());
|
||||
hash.finalize().into()
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! Public API checks for docs/CONTRACT.md, Stage 1 storage semantics.
|
||||
//!
|
||||
//! These tests deliberately avoid SQLite tables, section encodings, and other
|
||||
//! implementation details: durable state is inspected only by reopening the API.
|
||||
|
||||
use shacraft_core::{BlockChange, BlockId, Pos, WorldStore};
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn change(pos: Pos, block: BlockId) -> BlockChange {
|
||||
BlockChange { pos, block }
|
||||
}
|
||||
|
||||
fn fixture() -> (TempDir, WorldStore, BlockId, BlockId) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut store = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let stone = store.register_block("minecraft:stone").unwrap();
|
||||
let dirt = store.register_block("minecraft:dirt").unwrap();
|
||||
store.create_world("world", None).unwrap();
|
||||
(directory, store, stone, dirt)
|
||||
}
|
||||
|
||||
fn region(store: &mut WorldStore, world: &str) -> BTreeMap<Pos, BlockId> {
|
||||
store
|
||||
.read_region(world, [-16, 0, 0], [16, 0, 0])
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|cell| (cell.pos, cell.block))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_replay_precedes_revision_check_and_survives_reset_and_reopen() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let original = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"original",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!original.replayed);
|
||||
assert_eq!(original.changed, 1);
|
||||
let reset = store
|
||||
.reset_world("world", original.revision, "clear")
|
||||
.unwrap();
|
||||
let latest = store
|
||||
.edit(
|
||||
"world",
|
||||
reset.revision,
|
||||
"after-reset",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Replaying a committed operation returns its original result, without
|
||||
// checking its now-stale expected revision or reapplying its old changes.
|
||||
let replay = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"original",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, original.revision);
|
||||
assert_eq!(replay.changed, original.changed);
|
||||
assert_eq!(store.revision("world").unwrap(), latest.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), dirt);
|
||||
|
||||
// No flush is needed to make an acknowledged operation durable.
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let replay = reopened
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"original",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, original.revision);
|
||||
assert_eq!(replay.changed, original.changed);
|
||||
assert_eq!(reopened.revision("world").unwrap(), latest.revision);
|
||||
assert_eq!(reopened.get_block("world", [0, 0, 0]).unwrap(), dirt);
|
||||
assert_eq!(reopened.registry()[stone as usize], "minecraft:stone");
|
||||
assert_eq!(reopened.registry()[dirt as usize], "minecraft:dirt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_id_collision_rejects_changed_payload_across_restart_and_methods() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let committed = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"shared-id",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"shared-id",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reset_world("world", committed.revision, "shared-id")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), committed.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
assert!(
|
||||
reopened
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"shared-id",
|
||||
vec![change([1, 0, 0], stone)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(reopened.get_block("world", [1, 0, 0]).unwrap(), 0);
|
||||
assert_eq!(reopened.revision("world").unwrap(), committed.revision);
|
||||
|
||||
// IDs are scoped to their world, rather than reserved across the store.
|
||||
reopened.create_world("other", None).unwrap();
|
||||
let other_revision = reopened.revision("other").unwrap();
|
||||
let other = reopened
|
||||
.edit(
|
||||
"other",
|
||||
other_revision,
|
||||
"shared-id",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!other.replayed);
|
||||
assert_eq!(reopened.get_block("other", [0, 0, 0]).unwrap(), dirt);
|
||||
assert_eq!(reopened.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_never_partially_writes_or_advances_revision() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let seeded = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([0, 0, 0], stone), change([-1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let original = region(&mut store, "world");
|
||||
let cases = [
|
||||
(
|
||||
"unknown-block",
|
||||
vec![change([0, 0, 0], dirt), change([1, 0, 0], u32::MAX)],
|
||||
),
|
||||
(
|
||||
"duplicate-position",
|
||||
vec![change([0, 0, 0], dirt), change([0, 0, 0], 0)],
|
||||
),
|
||||
(
|
||||
"positive-overflow",
|
||||
vec![change([0, 0, 0], dirt), change([30_000_001, 0, 0], stone)],
|
||||
),
|
||||
(
|
||||
"negative-overflow",
|
||||
vec![change([0, 0, 0], dirt), change([0, -30_000_001, 0], stone)],
|
||||
),
|
||||
(
|
||||
"integer-extreme",
|
||||
vec![change([0, 0, 0], dirt), change([0, 0, i32::MIN], stone)],
|
||||
),
|
||||
];
|
||||
for (operation, changes) in cases {
|
||||
assert!(
|
||||
store
|
||||
.edit("world", seeded.revision, operation, changes)
|
||||
.is_err(),
|
||||
"invalid batch {operation} was accepted"
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), seeded.revision);
|
||||
assert_eq!(region(&mut store, "world"), original);
|
||||
}
|
||||
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"stale-revision",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"missing",
|
||||
seeded.revision,
|
||||
"missing-world",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.list_worlds().unwrap().len(), 1);
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
assert_eq!(reopened.revision("world").unwrap(), seeded.revision);
|
||||
assert_eq!(region(&mut reopened, "world"), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_pin_effective_source_contents_and_air_overrides() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let original = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([-16, 0, 0], stone), change([16, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("first", Some("world")).unwrap();
|
||||
let changed_source = store
|
||||
.edit(
|
||||
"world",
|
||||
original.revision,
|
||||
"change-source",
|
||||
vec![change([-16, 0, 0], 0), change([16, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("second", Some("world")).unwrap();
|
||||
|
||||
let first_revision = store.revision("first").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"first",
|
||||
first_revision,
|
||||
"override-inherited",
|
||||
vec![change([-16, 0, 0], 0), change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("grandchild", Some("first")).unwrap();
|
||||
|
||||
store
|
||||
.reset_world("world", changed_source.revision, "reset-source")
|
||||
.unwrap();
|
||||
let changed_first_revision = store.revision("first").unwrap();
|
||||
store
|
||||
.reset_world("first", changed_first_revision, "reset-first")
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 1).unwrap();
|
||||
assert!(region(&mut reopened, "world").is_empty());
|
||||
assert_eq!(
|
||||
region(&mut reopened, "first"),
|
||||
BTreeMap::from([([-16, 0, 0], stone), ([16, 0, 0], dirt)])
|
||||
);
|
||||
assert_eq!(
|
||||
region(&mut reopened, "second"),
|
||||
BTreeMap::from([([16, 0, 0], stone)])
|
||||
);
|
||||
assert_eq!(
|
||||
region(&mut reopened, "grandchild"),
|
||||
BTreeMap::from([([0, 0, 0], stone), ([16, 0, 0], dirt)])
|
||||
);
|
||||
// Repeated cross-section reads exercise eviction without exposing the codec.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(reopened.get_block("grandchild", [-16, 0, 0]).unwrap(), 0);
|
||||
assert_eq!(reopened.get_block("first", [-16, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(reopened.get_block("grandchild", [16, 0, 0]).unwrap(), dirt);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_is_monotonic_idempotent_durable_and_an_undo_barrier() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("arena", Some("world")).unwrap();
|
||||
let arena_initial = store.revision("arena").unwrap();
|
||||
let edited = store
|
||||
.edit(
|
||||
"arena",
|
||||
arena_initial,
|
||||
"remove-floor",
|
||||
vec![change([0, 0, 0], 0), change([1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let reset = store
|
||||
.reset_world("arena", edited.revision, "reset-round")
|
||||
.unwrap();
|
||||
assert!(!reset.replayed);
|
||||
assert!(reset.revision > edited.revision);
|
||||
assert_eq!(store.get_block("arena", [0, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(store.get_block("arena", [1, 0, 0]).unwrap(), 0);
|
||||
assert!(
|
||||
store
|
||||
.undo("arena", reset.revision, "past-barrier", "remove-floor")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let latest = store
|
||||
.edit(
|
||||
"arena",
|
||||
reset.revision,
|
||||
"next-round",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let replay = store
|
||||
.reset_world("arena", edited.revision, "reset-round")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, reset.revision);
|
||||
assert_eq!(replay.changed, reset.changed);
|
||||
assert_eq!(store.revision("arena").unwrap(), latest.revision);
|
||||
assert_eq!(store.get_block("arena", [0, 0, 0]).unwrap(), dirt);
|
||||
assert!(
|
||||
store
|
||||
.reset_world("arena", edited.revision, "stale-new-reset")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let replay = reopened
|
||||
.reset_world("arena", edited.revision, "reset-round")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, reset.revision);
|
||||
assert_eq!(reopened.revision("arena").unwrap(), latest.revision);
|
||||
assert_eq!(reopened.get_block("arena", [0, 0, 0]).unwrap(), dirt);
|
||||
let next_reset = reopened
|
||||
.reset_world("arena", latest.revision, "second-reset")
|
||||
.unwrap();
|
||||
assert!(next_reset.revision > latest.revision);
|
||||
assert_eq!(reopened.get_block("arena", [0, 0, 0]).unwrap(), stone);
|
||||
let already_at_base = reopened
|
||||
.reset_world("arena", next_reset.revision, "third-reset")
|
||||
.unwrap();
|
||||
assert!(already_at_base.revision > next_reset.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_inherited_content_and_replays_without_mutating_newer_state() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("child", Some("world")).unwrap();
|
||||
let child_revision = store.revision("child").unwrap();
|
||||
let edited = store
|
||||
.edit(
|
||||
"child",
|
||||
child_revision,
|
||||
"break-and-add",
|
||||
vec![change([0, 0, 0], 0), change([1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let undone = store
|
||||
.undo("child", edited.revision, "undo-edit", "break-and-add")
|
||||
.unwrap();
|
||||
assert!(!undone.replayed);
|
||||
assert!(undone.revision > edited.revision);
|
||||
assert_eq!(store.get_block("child", [0, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(store.get_block("child", [1, 0, 0]).unwrap(), 0);
|
||||
assert!(
|
||||
store
|
||||
.undo("child", undone.revision, "undo-an-undo", "undo-edit")
|
||||
.is_err()
|
||||
);
|
||||
let later = store
|
||||
.edit(
|
||||
"child",
|
||||
undone.revision,
|
||||
"later",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let replay = reopened
|
||||
.undo("child", edited.revision, "undo-edit", "break-and-add")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, undone.revision);
|
||||
assert_eq!(replay.changed, undone.changed);
|
||||
assert_eq!(reopened.revision("child").unwrap(), later.revision);
|
||||
assert_eq!(reopened.get_block("child", [0, 0, 0]).unwrap(), dirt);
|
||||
assert!(
|
||||
reopened
|
||||
.undo("child", edited.revision, "undo-edit", "later")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(reopened.revision("child").unwrap(), later.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_undo_rejects_later_nonoverlapping_edits_and_aba_cycles() {
|
||||
let (_directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let target = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"target",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
let nonoverlapping = store
|
||||
.edit(
|
||||
"world",
|
||||
target.revision,
|
||||
"nonoverlapping",
|
||||
vec![change([1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.undo("world", nonoverlapping.revision, "strict-undo", "target",)
|
||||
.is_err()
|
||||
);
|
||||
let to_b = store
|
||||
.edit(
|
||||
"world",
|
||||
nonoverlapping.revision,
|
||||
"to-b",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let back_to_a = store
|
||||
.edit(
|
||||
"world",
|
||||
to_b.revision,
|
||||
"back-to-a",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.undo("world", back_to_a.revision, "aba-undo", "target")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), back_to_a.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(store.get_block("world", [1, 0, 0]).unwrap(), dirt);
|
||||
|
||||
// The immediately preceding edit can still be undone after a rejected undo.
|
||||
let valid = store
|
||||
.undo("world", back_to_a.revision, "undo-latest", "back-to-a")
|
||||
.unwrap();
|
||||
assert!(valid.revision > back_to_a.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), dirt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_noop_advances_revision_and_prevents_undo_of_earlier_edit() {
|
||||
let (_directory, mut store, stone, _dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let target = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"target",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
let noop = store
|
||||
.edit(
|
||||
"world",
|
||||
target.revision,
|
||||
"same-value",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(noop.changed, 0);
|
||||
assert!(!noop.replayed);
|
||||
assert!(noop.revision > target.revision);
|
||||
assert!(
|
||||
store
|
||||
.undo("world", noop.revision, "undo-past-noop", "target")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), noop.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
let replay = store
|
||||
.edit(
|
||||
"world",
|
||||
target.revision,
|
||||
"same-value",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.changed, 0);
|
||||
assert_eq!(replay.revision, noop.revision);
|
||||
assert_eq!(store.revision("world").unwrap(), noop.revision);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use shacraft_core::{BlockChange, COORD_LIMIT, MAX_READ_VOLUME, WorldStore};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn change(pos: [i32; 3], block: u32) -> BlockChange {
|
||||
BlockChange { pos, block }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_cache_evicts_distinct_blobs_and_shares_identical_sections() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 2)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
// Different local positions create 6 distinct immutable section payloads.
|
||||
let changes = (0..6).map(|i| change([i * 16 + i, 0, 0], stone)).collect();
|
||||
store.edit("main", 0, "distinct", changes)?;
|
||||
assert_eq!(store.stats()["immutable_blob_count"], 6);
|
||||
for i in 0..6 {
|
||||
assert_eq!(store.get_block("main", [i * 16 + i, 0, 0])?, stone);
|
||||
assert_eq!(store.get_block("main", [i * 16 + (i + 1) % 16, 0, 0])?, 0);
|
||||
let stats = store.stats();
|
||||
assert!(stats["cache_entries"].as_u64().unwrap() <= 2);
|
||||
assert!(stats["cache_payload_bytes"].as_u64().unwrap() < 2 * 16384);
|
||||
}
|
||||
assert_eq!(store.stats()["cache_entries"], 2);
|
||||
assert!(store.stats()["cache_evictions"].as_u64().unwrap() >= 4);
|
||||
store.create_world("child", Some("main"))?;
|
||||
let count = store.stats()["immutable_blob_count"].clone();
|
||||
assert_eq!(count, 6);
|
||||
let before_hits = store.stats()["cache_hits"].as_u64().unwrap();
|
||||
assert_eq!(store.get_block("child", [85, 0, 0])?, stone);
|
||||
assert!(store.stats()["cache_hits"].as_u64().unwrap() > before_hits);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_section_remains_twenty_bytes_in_resident_cache() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
let mut changes = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
changes.push(change([x, y, z], stone));
|
||||
}
|
||||
}
|
||||
}
|
||||
store.edit("main", 0, "fill", changes)?;
|
||||
assert_eq!(store.get_block("main", [15, 15, 15])?, stone);
|
||||
assert_eq!(store.stats()["cache_payload_bytes"], 20);
|
||||
assert_eq!(store.stats()["cache_entries"], 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_boundaries_and_inclusive_read_limits() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
let positions = [
|
||||
[-COORD_LIMIT, -COORD_LIMIT, -COORD_LIMIT],
|
||||
[COORD_LIMIT, COORD_LIMIT, COORD_LIMIT],
|
||||
[-17, -16, -1],
|
||||
[-16, -1, 0],
|
||||
[-1, 0, 15],
|
||||
[0, 15, 16],
|
||||
[15, 16, 17],
|
||||
];
|
||||
store.edit(
|
||||
"main",
|
||||
0,
|
||||
"edges",
|
||||
positions
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|p| change(p, stone))
|
||||
.collect(),
|
||||
)?;
|
||||
for pos in positions {
|
||||
assert_eq!(store.get_block("main", pos)?, stone);
|
||||
assert_eq!(
|
||||
store.read_region("main", pos, pos)?,
|
||||
vec![change(pos, stone)]
|
||||
);
|
||||
}
|
||||
assert!(store.get_block("main", [i32::MIN, 0, 0]).is_err());
|
||||
assert!(store.get_block("main", [0, i32::MAX, 0]).is_err());
|
||||
assert!(store.read_region("main", [0, 0, 0], [63, 63, 63]).is_ok());
|
||||
assert!(store.read_region("main", [0, 0, 0], [64, 63, 63]).is_err());
|
||||
assert!(store.read_region("main", [1, 0, 0], [0, 0, 0]).is_err());
|
||||
assert!(
|
||||
store
|
||||
.read_region("main", [-COORD_LIMIT; 3], [COORD_LIMIT; 3])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(MAX_READ_VOLUME, 64 * 64 * 64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_is_exclusive_and_released_on_drop() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
assert!(WorldStore::open(dir.path(), 0).is_err());
|
||||
let store = WorldStore::open(dir.path(), 1)?;
|
||||
assert!(WorldStore::open(dir.path(), 1).is_err());
|
||||
drop(store);
|
||||
let _reopened = WorldStore::open(dir.path(), 1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_failure_mid_edit_rolls_back_blobs_history_and_revision() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
drop(store);
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute_batch(
|
||||
"CREATE TRIGGER injected_failure BEFORE INSERT ON world_sections
|
||||
WHEN NEW.x=1 BEGIN SELECT RAISE(FAIL,'injected write failure'); END;",
|
||||
)?;
|
||||
drop(db);
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"main",
|
||||
0,
|
||||
"fail",
|
||||
vec![change([0, 0, 0], stone), change([16, 0, 0], stone)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("main")?, 0);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
assert_eq!(store.get_block("main", [16, 0, 0])?, 0);
|
||||
assert_eq!(store.stats()["operation_count"], 0);
|
||||
assert_eq!(store.stats()["history_change_count"], 0);
|
||||
assert_eq!(store.stats()["immutable_blob_count"], 0);
|
||||
// A failed operation ID was never consumed.
|
||||
assert!(
|
||||
!store
|
||||
.edit("main", 0, "fail", vec![change([0, 0, 0], stone)])?
|
||||
.replayed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_gc_preserves_snapshots_history_and_cache_correctness() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 2)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
let glass = store.register_block("minecraft:glass")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "stone", vec![change([0, 0, 0], stone)])?;
|
||||
store.create_world("child", Some("main"))?;
|
||||
store.edit("main", 1, "glass", vec![change([0, 0, 0], glass)])?;
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, glass);
|
||||
store.reset_world("main", 2, "reset")?;
|
||||
assert_eq!(store.collect_garbage(1)?, 1);
|
||||
assert_eq!(store.get_block("child", [0, 0, 0])?, stone);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
let original = store.edit("main", 0, "stone", vec![change([0, 0, 0], stone)])?;
|
||||
assert!(original.replayed);
|
||||
assert_eq!(original.revision, 1);
|
||||
// New blob rowid can reuse the deleted ID; cache must not serve old data.
|
||||
store.edit("main", 3, "new", vec![change([1, 0, 0], glass)])?;
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
assert_eq!(store.get_block("main", [1, 0, 0])?, glass);
|
||||
assert!(store.collect_garbage(0).is_err());
|
||||
assert!(store.collect_garbage(4097).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_canonical_durable_and_rejects_malformed_identifiers() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
assert_eq!(store.register_block("minecraft:air")?, 0);
|
||||
let id = store.register_block("example:machine[z=true,a=7]")?;
|
||||
assert_eq!(store.registry()[id as usize], "example:machine[a=7,z=true]");
|
||||
assert_eq!(store.register_block("example:machine[a=7,z=true]")?, id);
|
||||
for bad in [
|
||||
"stone",
|
||||
":stone",
|
||||
"Minecraft:stone",
|
||||
"minecraft:stone[]",
|
||||
"minecraft:stone[a=x,a=y]",
|
||||
"minecraft:stone[a=x",
|
||||
"minecraft:stone[a=x]=y",
|
||||
] {
|
||||
assert!(store.register_block(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
let registry = store.registry().to_vec();
|
||||
drop(store);
|
||||
let reopened = WorldStore::open(dir.path(), 1)?;
|
||||
assert_eq!(reopened.registry(), registry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_blob_is_reported_instead_of_air() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "edit", vec![change([0, 0, 0], stone)])?;
|
||||
drop(store);
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute("UPDATE blobs SET data=?1", [vec![0_u8; 20]])?;
|
||||
drop(db);
|
||||
let mut reopened = WorldStore::open(dir.path(), 1)?;
|
||||
let error = reopened.get_block("main", [0, 0, 0]).unwrap_err();
|
||||
assert!(error.to_string().contains("storage_corrupt"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_unreachable_blob_is_rejected_before_reinterning() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "stone", vec![change([0, 0, 0], stone)])?;
|
||||
store.edit("main", 1, "air", vec![change([0, 0, 0], 0)])?;
|
||||
drop(store);
|
||||
// Keep the old digest but corrupt a now-unreachable blob's contents. A
|
||||
// later intern must not fetch this BLOB into a Vec before checking length.
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute("UPDATE blobs SET data=zeroblob(1000000)", [])?;
|
||||
drop(db);
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let error = store
|
||||
.edit("main", 2, "again", vec![change([0, 0, 0], stone)])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("storage_corrupt"));
|
||||
assert_eq!(store.revision("main")?, 2);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
assert_eq!(store.stats()["operation_count"], 2);
|
||||
assert_eq!(store.stats()["cache_entries"], 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_overflow_rejects_mutation_but_keeps_original_replay() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "edit", vec![change([0, 0, 0], stone)])?;
|
||||
drop(store);
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute("UPDATE worlds SET revision=?1", params![i64::MAX])?;
|
||||
drop(db);
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"main",
|
||||
i64::MAX as u64,
|
||||
"overflow",
|
||||
vec![change([0, 0, 0], 0)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("main")?, i64::MAX as u64);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, stone);
|
||||
assert!(
|
||||
store
|
||||
.edit("main", 0, "edit", vec![change([0, 0, 0], stone)])?
|
||||
.replayed
|
||||
);
|
||||
assert_eq!(store.stats()["operation_count"], 1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "shacraft-tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shacraft-core.workspace = true
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Local storage probe. JSON-lines session is a test/CLI interface, not MCP.
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shacraft_core::{BlockChange, Pos, WorldStore};
|
||||
use std::{
|
||||
io::{self, BufRead, Write},
|
||||
path::PathBuf,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "Shacraft Core storage tools (stage 1)")]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "data")]
|
||||
data: PathBuf,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
cache: usize,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Read JSON requests, one per line. Flush each reply after the API returns.
|
||||
Session,
|
||||
/// Show storage metrics; estimated payload and OS RSS remain separate.
|
||||
Stats,
|
||||
/// Exercise shared snapshots, independent edits, undo and reset.
|
||||
Demo,
|
||||
/// Reproducible storage workload. Requires an empty world store.
|
||||
Benchmark {
|
||||
#[arg(long, default_value_t = 256)]
|
||||
sections: usize,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
worlds: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum Request {
|
||||
List,
|
||||
Register {
|
||||
state: String,
|
||||
},
|
||||
Registry,
|
||||
Create {
|
||||
name: String,
|
||||
template: Option<String>,
|
||||
},
|
||||
Revision {
|
||||
world: String,
|
||||
},
|
||||
Get {
|
||||
world: String,
|
||||
pos: Pos,
|
||||
},
|
||||
Read {
|
||||
world: String,
|
||||
min: Pos,
|
||||
max: Pos,
|
||||
},
|
||||
Edit {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
changes: Vec<BlockChange>,
|
||||
},
|
||||
Undo {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
target_operation: String,
|
||||
},
|
||||
Reset {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
},
|
||||
Stats,
|
||||
Flush,
|
||||
}
|
||||
|
||||
fn execute(store: &mut WorldStore, request: Request) -> Result<Value> {
|
||||
Ok(match request {
|
||||
Request::List => json!(store.list_worlds()?),
|
||||
Request::Register { state } => json!(store.register_block(&state)?),
|
||||
Request::Registry => json!(store.registry()),
|
||||
Request::Create { name, template } => {
|
||||
store.create_world(&name, template.as_deref())?;
|
||||
json!({"name":name, "revision":store.revision(&name)?})
|
||||
}
|
||||
Request::Revision { world } => json!(store.revision(&world)?),
|
||||
Request::Get { world, pos } => json!(store.get_block(&world, pos)?),
|
||||
Request::Read { world, min, max } => json!(store.read_region(&world, min, max)?),
|
||||
Request::Edit {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
changes,
|
||||
} => json!(store.edit(&world, expected_revision, &operation_id, changes)?),
|
||||
Request::Undo {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
target_operation,
|
||||
} => json!(store.undo(&world, expected_revision, &operation_id, &target_operation)?),
|
||||
Request::Reset {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
} => json!(store.reset_world(&world, expected_revision, &operation_id)?),
|
||||
Request::Stats => metrics(store),
|
||||
Request::Flush => {
|
||||
store.flush()?;
|
||||
json!({"flushed":true})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn os_memory() -> Value {
|
||||
// Linux measurements. Unavailable on other platforms is null, never zero.
|
||||
let read = std::fs::read_to_string("/proc/self/status").ok();
|
||||
let field = |key: &str| -> Option<u64> {
|
||||
read.as_ref()?
|
||||
.lines()
|
||||
.find(|line| line.starts_with(key))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()?
|
||||
.checked_mul(1024)
|
||||
};
|
||||
json!({"rss_bytes":field("VmRSS:"), "peak_rss_bytes":field("VmHWM:"),
|
||||
"source":"/proc/self/status (null when unavailable)"})
|
||||
}
|
||||
|
||||
fn metrics(store: &WorldStore) -> Value {
|
||||
json!({"storage":store.stats(), "process":os_memory()})
|
||||
}
|
||||
|
||||
fn session(store: &mut WorldStore) -> Result<()> {
|
||||
let stdin = io::stdin();
|
||||
let mut input = stdin.lock();
|
||||
let stdout = io::stdout();
|
||||
let mut output = stdout.lock();
|
||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let available = input.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
break;
|
||||
}
|
||||
let n = available
|
||||
.iter()
|
||||
.position(|&c| c == b'\n')
|
||||
.map_or(available.len(), |n| n + 1);
|
||||
if line.len().saturating_add(n) > MAX_LINE {
|
||||
bail!("request exceeds 8 MiB");
|
||||
}
|
||||
line.extend_from_slice(&available[..n]);
|
||||
input.consume(n);
|
||||
if line.last() == Some(&b'\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let result = serde_json::from_slice::<Request>(&line)
|
||||
.context("invalid request")
|
||||
.and_then(|request| execute(store, request));
|
||||
let reply = match result {
|
||||
Ok(result) => json!({"ok":true, "result":result}),
|
||||
Err(error) => json!({"ok":false, "error":format!("{error:#}")}),
|
||||
};
|
||||
serde_json::to_writer(&mut output, &reply)?;
|
||||
writeln!(output)?;
|
||||
output.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_empty(store: &WorldStore) -> Result<()> {
|
||||
if !store.list_worlds()?.is_empty() {
|
||||
bail!("demo/benchmark requires an empty store; use a fresh --data directory");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn demo(store: &mut WorldStore) -> Result<Value> {
|
||||
require_empty(store)?;
|
||||
let stone = store.register_block("shacraft:stone")?;
|
||||
store.create_world("template", None)?;
|
||||
let changes = (-16..16)
|
||||
.flat_map(|x| {
|
||||
(-16..16).map(move |z| BlockChange {
|
||||
pos: [x, 0, z],
|
||||
block: stone,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
store.edit("template", 0, "build-floor", changes)?;
|
||||
store.create_world("arena_a", Some("template"))?;
|
||||
store.create_world("arena_b", Some("template"))?;
|
||||
let edited = store.edit(
|
||||
"arena_a",
|
||||
0,
|
||||
"break-floor",
|
||||
vec![BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: 0,
|
||||
}],
|
||||
)?;
|
||||
anyhow::ensure!(
|
||||
store.get_block("arena_b", [0, 0, 0])? == stone,
|
||||
"fork isolation failed"
|
||||
);
|
||||
let undo = store.undo("arena_a", edited.revision, "restore-floor", "break-floor")?;
|
||||
let reset = store.reset_world("arena_a", undo.revision, "reset-match")?;
|
||||
store.flush()?;
|
||||
Ok(
|
||||
json!({"scenario":"shared floor -> isolated edit -> undo -> reset", "worlds":store.list_worlds()?,
|
||||
"edit":edited,"undo":undo,"reset":reset,"metrics":metrics(store)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn benchmark(store: &mut WorldStore, sections: usize, worlds: usize) -> Result<Value> {
|
||||
require_empty(store)?;
|
||||
anyhow::ensure!((1..=4096).contains(§ions), "sections must be 1..4096");
|
||||
anyhow::ensure!((1..=1000).contains(&worlds), "worlds must be 1..1000");
|
||||
let start = Instant::now();
|
||||
let stone = store.register_block("shacraft:stone")?;
|
||||
store.create_world("template", None)?;
|
||||
// Each section differs, so this probes eviction as well as content sharing.
|
||||
for section in 0..sections {
|
||||
let sx = (section % 64) as i32 * 16;
|
||||
let sz = (section / 64) as i32 * 16;
|
||||
let changes = (0..256)
|
||||
.map(|cell| BlockChange {
|
||||
pos: [sx + cell % 16, 0, sz + cell / 16],
|
||||
block: if cell as usize == section % 256 {
|
||||
0
|
||||
} else {
|
||||
stone
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
store.edit(
|
||||
"template",
|
||||
store.revision("template")?,
|
||||
&format!("seed-{section}"),
|
||||
changes,
|
||||
)?;
|
||||
}
|
||||
let seeded_ms = start.elapsed().as_millis();
|
||||
let after_template = metrics(store);
|
||||
let fork_start = Instant::now();
|
||||
for world in 0..worlds {
|
||||
store.create_world(&format!("arena_{world}"), Some("template"))?;
|
||||
}
|
||||
let fork_ms = fork_start.elapsed().as_millis();
|
||||
let after_forks = metrics(store);
|
||||
for section in 0..sections {
|
||||
let pos = [(section % 64) as i32 * 16, 0, (section / 64) as i32 * 16];
|
||||
store.get_block("template", pos)?;
|
||||
}
|
||||
for world in 0..worlds {
|
||||
let name = format!("arena_{world}");
|
||||
store.edit(
|
||||
&name,
|
||||
0,
|
||||
"isolated",
|
||||
vec![BlockChange {
|
||||
pos: [1, 1, 1],
|
||||
block: stone,
|
||||
}],
|
||||
)?;
|
||||
store.reset_world(&name, 1, "reset")?;
|
||||
anyhow::ensure!(store.get_block(&name, [1, 1, 1])? == 0, "reset failed");
|
||||
}
|
||||
store.flush()?;
|
||||
Ok(
|
||||
json!({"scenario":"storage-only; no players, rendering or tick simulation",
|
||||
"comparison":"no Paper baseline measured", "sections":sections, "forked_worlds":worlds,
|
||||
"seed_ms":seeded_ms, "fork_ms":fork_ms, "elapsed_ms":start.elapsed().as_millis(),
|
||||
"after_template":after_template,"after_forks":after_forks,"after_access_edit_reset":metrics(store)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let mut store = WorldStore::open(&args.data, args.cache)?;
|
||||
let result = match args.command {
|
||||
Command::Session => return session(&mut store),
|
||||
Command::Stats => metrics(&store),
|
||||
Command::Demo => demo(&mut store)?,
|
||||
Command::Benchmark { sections, worlds } => benchmark(&mut store, sections, worlds)?,
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Приёмка Shacraft Core
|
||||
|
||||
Статус: план проверок; ни один пункт пока не считается выполненным. Основа — исходный запрос пользователя и текущий `docs/CONTRACT.md`. Последний описывает первый рабочий протокол, но пока не покрывает весь согласованный MVP.
|
||||
Статус: план проверок; фактические результаты первого этапа находятся в `STATUS.md` и `VERIFICATION.md`. Рубежи A и B целиком ещё не пройдены. Основа — исходный запрос пользователя и текущий `docs/CONTRACT.md`. Последний описывает первый рабочий протокол, но пока не покрывает весь согласованный MVP.
|
||||
|
||||
Shacraft Core — самостоятельный открытый движок на Rust. Shacraft-сервер и Spleef проверяют его пригодность на практике. Экономия серверной RAM — основное архитектурное требование. Полный базовый каталог Minecraft 26.2 означает контент, состояния и формы, а не требование воспроизвести архитектуру Minecraft или весь ванильный игровой процесс.
|
||||
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
# Implementation contract, v1
|
||||
|
||||
Core Rust API (public in shacraft_core):
|
||||
Stage 1 core Rust API (public in shacraft_core):
|
||||
`type Pos = [i32;3]; type BlockId = u32;`
|
||||
`WorldStore::open(path: impl AsRef<Path>, cache_sections: usize) -> anyhow::Result<Self>`
|
||||
`list_worlds(&self) -> Result<Vec<WorldInfo>>`; `create_world(&mut self, name: &str, template: Option<&str>) -> Result<()>`; `reset_world(&mut self, name: &str, expected_revision: u64, operation_id: &str) -> Result<EditResult>`.
|
||||
@@ -10,6 +10,7 @@ Core Rust API (public in shacraft_core):
|
||||
`undo(&mut self, world: &str, expected_revision: u64, operation_id: &str, target_operation: &str) -> Result<EditResult>`.
|
||||
`read_region(&mut self, world: &str, min: Pos, max: Pos) -> Result<Vec<BlockChange>>` returns nonair cells, bounds inclusive, capped at 262144 cells.
|
||||
`stats(&self) -> serde_json::Value`; `flush(&mut self) -> Result<()>`.
|
||||
`collect_garbage(&mut self, max_blobs: usize) -> Result<usize>` deletes at most 1..4096 unreachable blobs per call, preserving snapshots and operation history. This limits deletions, not SQL scan latency, and does not vacuum the database file.
|
||||
`BlockChange { pub pos: Pos, pub block: BlockId }` derives Serialize/Deserialize/Clone.
|
||||
`EditResult { pub revision: u64, pub changed: usize, pub replayed: bool }` derives Serialize/Deserialize/Clone.
|
||||
`WorldInfo { pub name: String, pub revision: u64, pub template: Option<String> }` derives Serialize/Deserialize/Clone.
|
||||
@@ -20,14 +21,17 @@ Stage 1 storage semantics (accepted before implementation):
|
||||
- create_world(template) snapshots the source's current effective contents using references to immutable section blobs. The source's subsequent edits/reset never alter the child. Snapshot metadata stays disk indexed; creating the first snapshot can cost O(number of stored section references), without expanding all blocks in RAM.
|
||||
- reset returns the instance to its pinned base (or air for worlds without a base), increments revision monotonically, and has expected_revision/idempotency checks just like edit. A reset is a barrier for undo of older operations.
|
||||
- Operation IDs scoped per world, bounded UTF-8 strings. Look up idempotency before comparing the current revision. Repeating identical payload returns the stored result with replayed=true; same ID with a different payload is an error. Results survive restart and resets.
|
||||
- Every new successful edit (including changed=0) increments the revision; an exact replay does not. This is an explicit initial API policy, and no-op edits are also undo barriers.
|
||||
- Transactions publish and are acknowledged only after durable commit. Registry additions are durable. All input validation precedes mutation. Revisions fit SQLite's positive signed 64-bit integer range; reject overflow.
|
||||
- Undo is atomic and conflict aware. It may conservatively require current revision==the target edit's resulting revision (safe initial implementation, explicitly documented); selective nonoverlapping undo is a later extension. Never overwrite later writes or ABA cycles silently. Undoing an undo/reset is unsupported initially.
|
||||
- Default transaction limit 32768 cells; read_region limit 262144 volume; inclusive bounds and checked arithmetic. Reject duplicate cell positions in one edit, unknown BlockId, nonexistent world, malformed name, negative/overflowing volume and excessive input before writing.
|
||||
- World names match [A-Za-z0-9_-]{1,64}; block canonical names and registry cardinality are bounded. Minimum supported coordinate domain [-30000000,30000000] for every axis with checked validation. Air ID 0 cannot be reassigned. registry returns owned canonical identifiers shared by worlds; bounds and its RAM footprint are reported.
|
||||
- Section encoding versioned and defensively decoded: uniform or minimal-width palette + packed indices. Deduplicate immutable blobs; references are stored in indexed SQL tables. GC of unreachable blobs is explicit and bounded; disk retention is documented separately from RAM accounting.
|
||||
- Diagnostics distinguish decoded payload estimates, cache entries/capacity, SQLite cache configuration, registry bytes, disk data and measured OS RSS. Do not call estimated payload size total server RAM.
|
||||
- Implemented cache stores immutable encoded sections (uniform/palette/dense); `cache_payload_bytes` sums encoded lengths including their headers, excluding allocator/map overhead. `transient_decoded_payload_peak_estimate_bytes` counts temporary section-array payload, not total transient allocation. SQLite page cache budget is 4 MiB (SQLite setting, not a hard process RSS cap).
|
||||
- Implemented cardinality limits: registry 262144 states with at most 512 ASCII bytes per canonical state; worlds 10000; operation IDs 1..128 UTF-8 bytes without control characters. Registry strings reside in RAM and are accounted separately. Operation history is disk indexed and retained indefinitely in this initial release; explicit GC does not remove it.
|
||||
|
||||
Server protocol (JSON, WebSocket /ws):
|
||||
Planned Stage 2 server protocol (JSON, WebSocket /ws; not implemented in Stage 1):
|
||||
Client first: {type:'join',name:'Player',world:'lobby',manifest_hash:'…'}.
|
||||
Server welcome: {type:'welcome',id,world,revision,registry:[canonical block states],blocks:[{pos:[x,y,z],block:id}],players:[],spawn:[x,y,z],manifest_hash}.
|
||||
Client inputs at <=30Hz: {type:'input',seq,yaw,pitch,forward,strafe,jump}; yaw/pitch radians; forward/strafe in [-1,1].
|
||||
@@ -38,5 +42,5 @@ Server edit: {type:'blocks',revision,changes:[{pos,block}]}.
|
||||
Server error: {type:'error',message}; chat {type:'chat',name,text}.
|
||||
Client fetch GET /api/manifest -> {protocol:1,hash,packages:[],...}; GET /api/worlds -> [{name,revision,template}]; GET /api/catalog -> [{id,state,color,solid,shape}]. Server serves client from / .
|
||||
Control HTTP: GET /api/health, /api/metrics, /api/worlds, /api/catalog public; POST /api/control with Authorization: Bearer token and {method,params}, JSON return {result:...} or {error:'...'}.
|
||||
Control methods: world.list, world.create {name,template?}, world.reset {world}, world.read {world,min,max}, world.edit {world,expected_revision,operation_id,changes}, world.undo {world,expected_revision,operation_id,target_operation}, catalog.search {query,limit}, metrics, arena.start {world}, camera.capture {world,min?,max?} (top-down PNG), entity.spawn {world,kind,position}, entity.list {world}.
|
||||
Control methods: world.list, world.create {name,template?}, world.reset {world,expected_revision,operation_id}, world.read {world,min,max}, world.edit {world,expected_revision,operation_id,changes}, world.undo {world,expected_revision,operation_id,target_operation}, catalog.search {query,limit}, metrics, arena.start {world}, camera.capture {world,min?,max?} (top-down PNG), entity.spawn {world,kind,position}, entity.list {world}.
|
||||
Default server host 127.0.0.1 port 4000, token file data/control.token (0600). No publishing or production access.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Разработка и воспроизведение первого этапа
|
||||
|
||||
Это инструменты проверки хранилища. Здесь ещё нет игрового сервера, графического клиента или MCP. Локальный JSON-lines интерфейс CLI не является MCP.
|
||||
|
||||
## Сборка и проверки
|
||||
|
||||
Нужны Rust/Cargo 1.96.0, C-компилятор для bundled SQLite, Python 3 для проверки аварийного завершения. После загрузки зависимостей `Cargo.lock` фиксирует их версии.
|
||||
|
||||
```bash
|
||||
cd /home/emil/Desktop/shacraft-core
|
||||
bash scripts/verify.sh
|
||||
```
|
||||
|
||||
Проверка запускает форматирование, Clippy, Rust-тесты, сборку CLI и отдельные процессы. `check_storage.py` создаёт только временные миры и принудительно завершает только собственный дочерний процесс.
|
||||
|
||||
## Демонстрация хранения
|
||||
|
||||
```bash
|
||||
cargo run -p shacraft-tools -- --data data/demo demo
|
||||
cargo run -p shacraft-tools -- --data data/demo stats
|
||||
```
|
||||
|
||||
`demo` требует пустое хранилище. Создаёт карту пола и два независимых экземпляра, изменяет один, проверяет изоляцию, выполняет undo и reset. Повторный запуск в непустой каталог отклоняется. Для нового прогона укажите другое имя каталога.
|
||||
|
||||
## Измерение
|
||||
|
||||
```bash
|
||||
cargo build --release -p shacraft-tools
|
||||
target/release/shacraft-tools --data data/bench-001 --cache 8 benchmark --sections 256 --worlds 100
|
||||
```
|
||||
|
||||
`benchmark` также требует пустое хранилище. Он создаёт 256 различных секций, 100 экземпляров, проходит по данным сверх вместимости кэша и изменяет/сбрасывает каждый экземпляр. В JSON выводятся отдельные измерения RSS/пика всего процесса (на Linux), метрики хранения, длительности и размеры файлов. Данные теста синтетические; игроков, сетевого тика и фоновой симуляции нет. Эти числа не доказывают выигрыш относительно Paper.
|
||||
|
||||
## JSON-lines сессия
|
||||
|
||||
```bash
|
||||
target/debug/shacraft-tools --data data/manual --cache 8 session
|
||||
```
|
||||
|
||||
По одной JSON-команде на строку:
|
||||
|
||||
```json
|
||||
{"op":"register","state":"shacraft:stone"}
|
||||
{"op":"create","name":"world"}
|
||||
{"op":"registry"}
|
||||
{"op":"revision","world":"world"}
|
||||
{"op":"get","world":"world","pos":[-1,0,0]}
|
||||
{"op":"stats"}
|
||||
```
|
||||
|
||||
Сначала получите реальный ID блока и текущую ревизию, затем передайте их в `edit`:
|
||||
|
||||
```json
|
||||
{"op":"edit","world":"world","expected_revision":0,"operation_id":"first-stone","changes":[{"pos":[-1,0,0],"block":1}]}
|
||||
{"op":"undo","world":"world","expected_revision":1,"operation_id":"undo-first","target_operation":"first-stone"}
|
||||
{"op":"reset","world":"world","expected_revision":2,"operation_id":"reset-world"}
|
||||
```
|
||||
|
||||
ID 1 в этом примере допустим только если ответ регистрации действительно вернул 1. Ответ каждой команды имеет `ok` и `result` либо `error`. Успешный ответ записи выдаётся после возврата долговечного API. Максимальная входная строка — 8 MiB; ограничения на число блоков и объём области действуют дополнительно.
|
||||
|
||||
## Продолжение разработки
|
||||
|
||||
Прочитайте STATUS и PLAN. Новые компоненты добавляйте в workspace только вместе с реализацией и командами проверки. Не создавайте пустые исполняемые файлы, которые лишь выводят «готово». Публичный протокол пока проектируется в CONTRACT; окончание первого этапа хранения не закрывает последующие этапы.
|
||||
|
||||
Для живого SQLite-хранилища нельзя считать копию одного `worlds.sqlite3` полной резервной копией: актуальные данные могут быть в WAL. Перед ручным копированием остановите владеющий процесс либо используйте будущий согласованный backup API. Исходный архив проекта намеренно не содержит рабочие миры.
|
||||
@@ -78,7 +78,7 @@
|
||||
Одна успешная мутация атомарно фиксирует:
|
||||
|
||||
1. Новые секции и изменения ссылок мира.
|
||||
2. Новую ревизию, если состояние мира изменилось.
|
||||
2. Новую ревизию принятой операции, включая no-op.
|
||||
3. Запись операции, её отпечаток и точный результат для повторной выдачи.
|
||||
4. Данные истории, необходимые для безопасного undo.
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
Отпечаток включает метод и все его значимые аргументы, включая `expected_revision`; порядок уникальных позиций канонизируется. Область уникальности идентификатора — стабильный внутренний ID мира, а не имя, которое в будущем может быть переиспользовано.
|
||||
|
||||
Для операции, реально не изменяющей блоки, предлагается сохранять идемпотентный результат с `changed=0` без увеличения ревизии. Пустые и повторные запросы не должны бесконечно увеличивать номер состояния. Такая операция всё же требует долговечной записи дедупликации.
|
||||
Принятая семантика первого этапа: каждая новая успешно принятая edit-операция повышает ревизию, даже при `changed=0`. Такая операция также ставит границу для консервативного undo и сохраняет долговечный результат дедупликации. Точный повтор операции не увеличивает ревизию.
|
||||
|
||||
Повтор возвращает ревизию исходной операции, которая может быть меньше текущей. Клиент не должен откатывать свою текущую ревизию по такому ответу. Ошибка после commit, но до доставки ответа, означает неопределённость для вызывающего кода: безопасный повтор использует тот же идентификатор и те же аргументы.
|
||||
|
||||
|
||||
+2
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
План составлен до реализации. Каждый этап заканчивается воспроизводимым результатом в локальном репозитории. `docs/STATUS.md` обновляется после проверок. Пункт не считается выполненным по наличию интерфейса, заглушки, каталога имён или успешной компиляции.
|
||||
|
||||
Текущий прогресс: этап 0 и первая реализация этапа 1 завершены. Проверены библиотека хранения, CLI и аварийное восстановление. Этапы 2–6 остаются впереди; подробности в STATUS и VERIFICATION.
|
||||
|
||||
## Этап 0. Сохранить контекст и проверить проектирование
|
||||
|
||||
- Записать исходные требования, решения, контракты и критерии приёмки.
|
||||
@@ -76,4 +78,3 @@
|
||||
## Рабочий порядок после планирования
|
||||
|
||||
Сначала этап 1. Параллельно с ним можно готовить протокол и клиент по согласованному контракту, изучать каталог/конвертер. Подключение этих частей, изменения общих интерфейсов и проверки выполняются последовательно. После каждого этапа — сохранить результат; не оставлять единственную копию в одноразовой среде.
|
||||
|
||||
|
||||
+34
-10
@@ -1,19 +1,43 @@
|
||||
# Состояние проекта
|
||||
|
||||
Обновлено: 2026-09-14.
|
||||
Обновлено: 2026-09-14. Рабочая папка: `/home/emil/Desktop/shacraft-core`.
|
||||
|
||||
## Подтверждено локально
|
||||
## Завершено: планирование и первый этап хранения
|
||||
|
||||
- Создан отдельный Git-репозиторий `/home/emil/Desktop/shacraft-core`.
|
||||
- Доступны Rust 1.96.0, Cargo 1.96.0, Node.js 22.22.3, Python 3.14.4.
|
||||
- Созданы workspace manifest, директории компонентов и первоначальный контракт. Компилируемой реализации пока нет.
|
||||
- Записаны требования, план и решения. Независимо проверяются план хранения, совместимость и критерии приёмки.
|
||||
План сохранён **до реализации**, отдельным Git-коммитом `ddfcef2`. Исходники старой облачной среды не восстановлены и не использованы как доказательство работы новой реализации.
|
||||
|
||||
## Ближайшие действия
|
||||
Работают две crate:
|
||||
|
||||
1. Завершить проверку документов и поправить контракт.
|
||||
2. Реализовать и проверить библиотеку ядра (этап 1).
|
||||
3. Добавить сервер/клиент и собственный MCP с проверкой сквозного сценария.
|
||||
- `shacraft-core`: versioned codec секций 16³ (uniform/palette/dense), компактный LRU, долговечный реестр, SQLite/WAL/FULL, блокировка второго писателя, неизменяемые снимки, независимые экземпляры, атомарные edit, revision/idempotency, undo/reset и явная уборка недостижимых blobs.
|
||||
- `shacraft-tools`: локальная JSON-lines сессия для тестирования API, demo, stats и воспроизводимый storage benchmark. Это не MCP и не игровой сервер.
|
||||
|
||||
Проверено на Linux x86_64 с Rust 1.96.0:
|
||||
|
||||
- `cargo fmt` и Clippy с запретом предупреждений.
|
||||
- 29 Rust-тестов: 11 codec, 8 black-box контрактов, 10 хранения.
|
||||
- Отдельные процессы: 80 подтверждённых правок → SIGKILL → восстановление реестра/данных/replay; прерывание транзакции на 32768 ячеек → отсутствие частичного состояния → безопасный повтор.
|
||||
- Вытеснение различных секций при превышении кэша; однородная секция остаётся компактной и в RAM.
|
||||
- Release demo и синтетический benchmark: 256 секций, 100 независимых экземпляров, чтение сверх кэша, edit/reset каждого экземпляра.
|
||||
|
||||
Команды и результаты: [DEVELOPMENT](DEVELOPMENT.md), [VERIFICATION](VERIFICATION.md), `docs/verification/`.
|
||||
|
||||
## Следующий этап: сервер и тестовый клиент
|
||||
|
||||
1. Добавить crate сервера с игровым циклом отдельно от долговечных редакторских транзакций. Не выполнять SQL/FULL commit каждого игрового ввода.
|
||||
2. Уточнить протокол resync, подписку без разрыва между снимком и изменениями, переключение миров и лимиты очередей.
|
||||
3. Реализовать серверные движение/коллизии/взаимодействия и минимальный WebGL2-клиент по решению D003.
|
||||
4. Пройти сценарий двух клиентов с общими блоками и независимыми мирами.
|
||||
5. Затем подключить собственный MCP через общий Control API, полный каталог и конвертер согласно PLAN.
|
||||
|
||||
## Ограничения первого этапа
|
||||
|
||||
- Полный MVP ещё не завершён. Нет игрового сервера, графического клиента, MCP, каталога 26.2, конвертера, системы исполняемых модулей и миниигры.
|
||||
- Undo консервативный: только целевая правка на текущей ревизии, без последующих операций. Каждый новый успешный edit, включая no-op, повышает ревизию. Retry не повышает.
|
||||
- История остаётся на диске без автоматического удаления. GC ограничивает число удалённых blobs, но не время сканирования. Он не удаляет историю/снимки и не уменьшает физический файл через VACUUM.
|
||||
- Реестр строк находится в RAM: максимум 262144 состояния по 512 ASCII-байт. Кэш секций не является лимитом всей памяти процесса. SQLite page-cache budget — настройка движка, не жёсткий RSS-лимит.
|
||||
- Benchmark проверяет хранение, не игроков/тики/полный каталог и не сравнительную экономию относительно Paper.
|
||||
- SIGKILL не имитирует отключение питания и отказы накопителя. Платформы вне Linux пока не проверены.
|
||||
- Продакшен, существующий лаунчер и удалённые репозитории не изменялись.
|
||||
|
||||
## Правила продолжения
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Проверки первого этапа, 14 сентября 2026
|
||||
|
||||
Проверены библиотека хранения и CLI. Рубеж A из ACCEPTANCE.md целиком ещё не пройден: сервер/клиент/MCP отсутствуют. Результаты ниже не объявляют полный MVP готовым.
|
||||
|
||||
## Автоматические проверки
|
||||
|
||||
Команда `bash scripts/verify.sh` завершилась с кодом 0. В [полном журнале](verification/checks.txt) сохранены:
|
||||
|
||||
- форматирование и Clippy `--workspace --all-targets --locked -- -D warnings` без ошибок;
|
||||
- 29 Rust-тестов (11 секционного формата, 8 публичных контрактов, 10 хранилища);
|
||||
- сборка CLI;
|
||||
- девять проверок отдельного процесса, включая принудительное завершение и восстановление.
|
||||
|
||||
В аварийном сценарии 80 правок были подтверждены процессом до SIGKILL и сохранились после повторного открытия. Повтор старой операции вернул её исходный результат без повторной правки. Большая операция на 32768 блоков, завершения которой вызывающий код не наблюдал, восстановилась целиком или отсутствовала целиком; безопасный повтор завершился на одной ревизии. Дополнительно SQL-тест принудительно вызывает отказ в середине изменения и проверяет откат данных, истории и ревизии.
|
||||
|
||||
## Проверка работы и хранения
|
||||
|
||||
`cargo build --release -p shacraft-tools --locked` завершилась успешно. [Результат demo](verification/demo.json) подтверждает сценарий общей карты, двух экземпляров, изолированной правки, undo и reset.
|
||||
|
||||
[Сырой результат benchmark](verification/storage-benchmark.json) получен командой:
|
||||
|
||||
```bash
|
||||
target/release/shacraft-tools --data <пустой-временный-каталог> --cache 8 benchmark --sections 256 --worlds 100
|
||||
```
|
||||
|
||||
После создания 100 экземпляров количество blobs осталось 256: карты разделяют неизменяемые данные. После обхода карты резидентный кэш остался в пределах восьми записей и зафиксировал вытеснения. Все экземпляры прошли независимую правку и сброс. RSS, размеры файлов и длительности сохранены в JSON вместе с версией Rust и системой.
|
||||
|
||||
Это один синтетический прогон на небольшом реестре из двух состояний, без сети, игроков и симуляции. Не использовать его для оценки готового сервера или сравнения с Paper. Байты закодированных секций не включают служебные структуры/аллокатор; RSS измеряется отдельно.
|
||||
|
||||
## Ограничения доказательств
|
||||
|
||||
Подтверждена работа на Linux x86_64. Тесты завершения процесса не имитируют отключение питания или аппаратные сбои. Часть защиты от повреждений проверяется на намеренно изменённых временных SQLite-базах; реальные миры пользователя не затрагиваются. Интеграция с Minecraft, лаунчером и MCP относится к следующим этапам и пока не проверялась.
|
||||
@@ -0,0 +1,123 @@
|
||||
Checking shacraft-core v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-core)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s
|
||||
Compiling shacraft-core v0.1.0 (/home/emil/Desktop/shacraft-core/crates/shacraft-core)
|
||||
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.46s
|
||||
Running unittests src/lib.rs (target/debug/deps/shacraft_core-20405a893c25a7e7)
|
||||
|
||||
running 11 tests
|
||||
test section::tests::direct_read_rejects_out_of_range_cell - should panic ... ok
|
||||
test section::tests::crc32_matches_standard_check_value ... ok
|
||||
test section::tests::packed_indices_match_cross_byte_wire_fixture ... ok
|
||||
test section::tests::uniform_round_trip_and_little_endian_layout ... ok
|
||||
test section::tests::invalid_palettes_and_indices_are_rejected ... ok
|
||||
test section::tests::structurally_invalid_headers_are_rejected_even_with_valid_checksum ... ok
|
||||
test section::tests::every_storage_mode_checks_registry_bounds ... ok
|
||||
test section::tests::dense_round_trip_and_size_crossover ... ok
|
||||
test section::tests::truncated_extra_and_corrupted_bytes_are_rejected ... ok
|
||||
test section::tests::palette_width_changes_at_powers_of_two ... ok
|
||||
test section::tests::every_palette_width_round_trips ... ok
|
||||
|
||||
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
|
||||
|
||||
Running tests/contract.rs (target/debug/deps/contract-fb06222454ea80ae)
|
||||
|
||||
running 8 tests
|
||||
test accepted_noop_advances_revision_and_prevents_undo_of_earlier_edit ... ok
|
||||
test edit_replay_precedes_revision_check_and_survives_reset_and_reopen ... ok
|
||||
test operation_id_collision_rejects_changed_payload_across_restart_and_methods ... ok
|
||||
test reset_is_monotonic_idempotent_durable_and_an_undo_barrier ... ok
|
||||
test invalid_batch_never_partially_writes_or_advances_revision ... ok
|
||||
test undo_restores_inherited_content_and_replays_without_mutating_newer_state ... ok
|
||||
test strict_undo_rejects_later_nonoverlapping_edits_and_aba_cycles ... ok
|
||||
test snapshots_pin_effective_source_contents_and_air_overrides ... ok
|
||||
|
||||
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
|
||||
|
||||
Running tests/storage.rs (target/debug/deps/storage-2b641b21f5b18d8e)
|
||||
|
||||
running 10 tests
|
||||
test registry_is_canonical_durable_and_rejects_malformed_identifiers ... ok
|
||||
test lock_is_exclusive_and_released_on_drop ... ok
|
||||
test corrupted_blob_is_reported_instead_of_air ... ok
|
||||
test revision_overflow_rejects_mutation_but_keeps_original_replay ... ok
|
||||
test explicit_gc_preserves_snapshots_history_and_cache_correctness ... ok
|
||||
test sqlite_failure_mid_edit_rolls_back_blobs_history_and_revision ... ok
|
||||
test oversized_unreachable_blob_is_rejected_before_reinterning ... ok
|
||||
test compact_cache_evicts_distinct_blobs_and_shares_identical_sections ... ok
|
||||
test coordinate_boundaries_and_inclusive_read_limits ... ok
|
||||
test uniform_section_remains_twenty_bytes_in_resident_cache ... ok
|
||||
|
||||
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
|
||||
|
||||
Running unittests src/main.rs (target/debug/deps/shacraft_tools-7c7bfbbbef840de7)
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Doc-tests shacraft_core
|
||||
|
||||
running 0 tests
|
||||
|
||||
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
||||
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
|
||||
{
|
||||
"passed": true,
|
||||
"checks": [
|
||||
"bounded resident cache with verified eviction while reading 80 sections",
|
||||
"second writer rejected while owner is running",
|
||||
"80 acknowledged edits and registry survive SIGKILL without flush",
|
||||
"idempotent replay after SIGKILL precedes current revision check",
|
||||
"invalid batch leaves both blocks and revision unchanged",
|
||||
"interrupted 32768-cell transaction is atomic (recovered revision 0)",
|
||||
"uncertain interrupted request can safely be retried",
|
||||
"invalid request does not terminate the local test session",
|
||||
"4096-cell uniform section occupies 20 encoded payload bytes in resident cache"
|
||||
],
|
||||
"final_metrics": {
|
||||
"process": {
|
||||
"peak_rss_bytes": 8237056,
|
||||
"rss_bytes": 8237056,
|
||||
"source": "/proc/self/status (null when unavailable)"
|
||||
},
|
||||
"storage": {
|
||||
"cache_capacity_sections": 2,
|
||||
"cache_entries": 1,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 1,
|
||||
"cache_payload_bytes": 20,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 0,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 647168,
|
||||
"history_change_count": 36944,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 18,
|
||||
"last_mutation_duration_us": 0,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 82,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 97,
|
||||
"registry_count": 2,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 32,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 0,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 16384,
|
||||
"wal_bytes": 0,
|
||||
"world_count": 3,
|
||||
"world_limit": 10000
|
||||
}
|
||||
},
|
||||
"limits": [
|
||||
"process crash tested; power loss and faulty hardware are not simulated",
|
||||
"storage-only workload; no Paper or gameplay performance comparison"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"edit": {
|
||||
"changed": 1,
|
||||
"replayed": false,
|
||||
"revision": 1
|
||||
},
|
||||
"metrics": {
|
||||
"process": {
|
||||
"peak_rss_bytes": 6131712,
|
||||
"rss_bytes": 6131712,
|
||||
"source": "/proc/self/status (null when unavailable)"
|
||||
},
|
||||
"storage": {
|
||||
"cache_capacity_sections": 64,
|
||||
"cache_entries": 2,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 3,
|
||||
"cache_misses": 2,
|
||||
"cache_payload_bytes": 1076,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 8,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 77824,
|
||||
"history_change_count": 1026,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 2,
|
||||
"last_mutation_duration_us": 99,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 4,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 4,
|
||||
"registry_count": 2,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 27,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 1,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 32768,
|
||||
"wal_bytes": 0,
|
||||
"world_count": 3,
|
||||
"world_limit": 10000
|
||||
}
|
||||
},
|
||||
"reset": {
|
||||
"changed": 0,
|
||||
"replayed": false,
|
||||
"revision": 3
|
||||
},
|
||||
"scenario": "shared floor -> isolated edit -> undo -> reset",
|
||||
"undo": {
|
||||
"changed": 1,
|
||||
"replayed": false,
|
||||
"revision": 2
|
||||
},
|
||||
"worlds": [
|
||||
{
|
||||
"name": "arena_a",
|
||||
"revision": 3,
|
||||
"template": "template"
|
||||
},
|
||||
{
|
||||
"name": "arena_b",
|
||||
"revision": 0,
|
||||
"template": "template"
|
||||
},
|
||||
{
|
||||
"name": "template",
|
||||
"revision": 1,
|
||||
"template": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"after_access_edit_reset": {
|
||||
"process": {
|
||||
"peak_rss_bytes": 7471104,
|
||||
"rss_bytes": 7471104,
|
||||
"source": "/proc/self/status (null when unavailable)"
|
||||
},
|
||||
"storage": {
|
||||
"cache_capacity_sections": 8,
|
||||
"cache_entries": 8,
|
||||
"cache_evictions": 249,
|
||||
"cache_hits": 299,
|
||||
"cache_misses": 257,
|
||||
"cache_payload_bytes": 4304,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 558,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 1343488,
|
||||
"history_change_count": 65380,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 257,
|
||||
"last_mutation_duration_us": 108,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 456,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 256,
|
||||
"registry_count": 2,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 27,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 1,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 32768,
|
||||
"wal_bytes": 0,
|
||||
"world_count": 101,
|
||||
"world_limit": 10000
|
||||
}
|
||||
},
|
||||
"after_forks": {
|
||||
"process": {
|
||||
"peak_rss_bytes": 7446528,
|
||||
"rss_bytes": 7446528,
|
||||
"source": "/proc/self/status (null when unavailable)"
|
||||
},
|
||||
"storage": {
|
||||
"cache_capacity_sections": 8,
|
||||
"cache_entries": 0,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"cache_payload_bytes": 0,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 358,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 1331200,
|
||||
"history_change_count": 65280,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 256,
|
||||
"last_mutation_duration_us": 44,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 256,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 256,
|
||||
"registry_count": 2,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 27,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 1,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 32768,
|
||||
"wal_bytes": 1095952,
|
||||
"world_count": 101,
|
||||
"world_limit": 10000
|
||||
}
|
||||
},
|
||||
"after_template": {
|
||||
"process": {
|
||||
"peak_rss_bytes": 7442432,
|
||||
"rss_bytes": 7442432,
|
||||
"source": "/proc/self/status (null when unavailable)"
|
||||
},
|
||||
"storage": {
|
||||
"cache_capacity_sections": 8,
|
||||
"cache_entries": 0,
|
||||
"cache_evictions": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"cache_payload_bytes": 0,
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"commits_this_handle": 258,
|
||||
"coordinate_limit": 30000000,
|
||||
"database_bytes": 1232896,
|
||||
"history_change_count": 65280,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"immutable_blob_count": 256,
|
||||
"last_mutation_duration_us": 675,
|
||||
"max_edit_cells": 32768,
|
||||
"max_read_volume": 262144,
|
||||
"operation_count": 256,
|
||||
"os_rss_bytes": null,
|
||||
"overlay_section_count": 256,
|
||||
"registry_count": 2,
|
||||
"registry_limit_states": 262144,
|
||||
"registry_text_bytes": 27,
|
||||
"schema_version": 1,
|
||||
"shm_bytes": 32768,
|
||||
"snapshot_count": 0,
|
||||
"sqlite_cache_budget_bytes": 4194304,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"transient_decoded_payload_peak_estimate_bytes": 32768,
|
||||
"wal_bytes": 1095952,
|
||||
"world_count": 1,
|
||||
"world_limit": 10000
|
||||
}
|
||||
},
|
||||
"comparison": "no Paper baseline measured",
|
||||
"elapsed_ms": 257,
|
||||
"fork_ms": 5,
|
||||
"forked_worlds": 100,
|
||||
"scenario": "storage-only; no players, rendering or tick simulation",
|
||||
"sections": 256,
|
||||
"seed_ms": 201,
|
||||
"environment": {
|
||||
"system": "Linux",
|
||||
"kernel": "7.0.0-31-generic",
|
||||
"arch": "x86_64",
|
||||
"rust": "rustc 1.96.0 (ac68faa20 2026-05-25)",
|
||||
"build": "release",
|
||||
"date": "2026-09-14"
|
||||
}
|
||||
}
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box durability checks using an independent OS process and real SIGKILL.
|
||||
|
||||
Only temporary test worlds are used. This is not a simulation of power loss.
|
||||
Run after cargo build -p shacraft-tools (or use --binary for release).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, binary, data):
|
||||
self.process = subprocess.Popen(
|
||||
[str(binary), "--data", str(data), "--cache", "2", "session"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8", bufsize=1,
|
||||
)
|
||||
|
||||
def request(self, request, success=True):
|
||||
self.process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
|
||||
self.process.stdin.flush()
|
||||
line = self.process.stdout.readline()
|
||||
if not line:
|
||||
raise AssertionError(f"worker exited: {self.process.stderr.read()}")
|
||||
result = json.loads(line)
|
||||
assert result["ok"] == success, result
|
||||
return result.get("result") if success else result["error"]
|
||||
|
||||
def close(self, abrupt=False):
|
||||
if abrupt:
|
||||
self.process.kill()
|
||||
else:
|
||||
self.process.stdin.close()
|
||||
self.process.wait(timeout=15)
|
||||
self.process.stdout.close()
|
||||
self.process.stderr.close()
|
||||
if abrupt:
|
||||
self.process.stdin.close()
|
||||
else:
|
||||
assert self.process.returncode == 0, self.process.returncode
|
||||
|
||||
|
||||
def check(binary, directory):
|
||||
checks = []
|
||||
active = Session(binary, directory)
|
||||
try:
|
||||
block = active.request({"op": "register", "state": "shacraft:test_stone"})
|
||||
active.request({"op": "create", "name": "main"})
|
||||
acknowledged = []
|
||||
for index in range(80):
|
||||
operation = {"op": "edit", "world": "main", "expected_revision": index,
|
||||
"operation_id": f"ack-{index}",
|
||||
"changes": [{"pos": [index * 16 - 640 + index % 16, 0, -1], "block": block}]}
|
||||
result = active.request(operation)
|
||||
assert result["revision"] == index + 1
|
||||
acknowledged.append(operation)
|
||||
for operation in acknowledged:
|
||||
assert active.request({"op": "get", "world": "main",
|
||||
"pos": operation["changes"][0]["pos"]}) == block
|
||||
stats = active.request({"op": "stats"})
|
||||
assert 0 < stats["storage"]["cache_entries"] <= 2, stats
|
||||
assert stats["storage"]["cache_evictions"] > 0, stats
|
||||
checks.append("bounded resident cache with verified eviction while reading 80 sections")
|
||||
|
||||
duplicate = subprocess.run([str(binary), "--data", str(directory), "stats"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
assert duplicate.returncode != 0, "second writer unexpectedly opened live store"
|
||||
checks.append("second writer rejected while owner is running")
|
||||
|
||||
# Every operation above has returned a flushed success reply. No flush command.
|
||||
active.close(abrupt=True)
|
||||
active = Session(binary, directory)
|
||||
assert active.request({"op": "revision", "world": "main"}) == 80
|
||||
assert active.request({"op": "register", "state": "shacraft:test_stone"}) == block
|
||||
for operation in acknowledged:
|
||||
pos = operation["changes"][0]["pos"]
|
||||
assert active.request({"op": "get", "world": "main", "pos": pos}) == block
|
||||
replay = active.request(acknowledged[0])
|
||||
assert replay["replayed"] and replay["revision"] == 1
|
||||
assert active.request({"op": "revision", "world": "main"}) == 80
|
||||
checks.append("80 acknowledged edits and registry survive SIGKILL without flush")
|
||||
checks.append("idempotent replay after SIGKILL precedes current revision check")
|
||||
|
||||
invalid = {"op": "edit", "world": "main", "expected_revision": 80,
|
||||
"operation_id": "invalid-atomic",
|
||||
"changes": [{"pos": [1, 1, 1], "block": block},
|
||||
{"pos": [2, 1, 1], "block": 4294967295}]}
|
||||
active.request(invalid, success=False)
|
||||
assert active.request({"op": "get", "world": "main", "pos": [1, 1, 1]}) == 0
|
||||
assert active.request({"op": "revision", "world": "main"}) == 80
|
||||
checks.append("invalid batch leaves both blocks and revision unchanged")
|
||||
|
||||
# A large request interrupted before observing the reply must be all-or-none.
|
||||
active.request({"op": "create", "name": "interrupted"})
|
||||
changes = [{"pos": [i % 64, (i // 64) % 8, i // 512], "block": block}
|
||||
for i in range(32768)]
|
||||
inflight = {"op": "edit", "world": "interrupted", "expected_revision": 0,
|
||||
"operation_id": "in-flight", "changes": changes}
|
||||
active.process.stdin.write(json.dumps(inflight, separators=(",", ":")) + "\n")
|
||||
active.process.stdin.flush()
|
||||
time.sleep(0.005)
|
||||
active.close(abrupt=True)
|
||||
active = Session(binary, directory)
|
||||
revision = active.request({"op": "revision", "world": "interrupted"})
|
||||
restored = active.request({"op": "read", "world": "interrupted",
|
||||
"min": [0, 0, 0], "max": [63, 7, 63]})
|
||||
assert (revision, len(restored)) in [(0, 0), (1, 32768)], (revision, len(restored))
|
||||
assert all(item["block"] == block for item in restored)
|
||||
checks.append(f"interrupted 32768-cell transaction is atomic (recovered revision {revision})")
|
||||
retry = active.request(inflight)
|
||||
assert retry["revision"] == 1
|
||||
assert active.request({"op": "revision", "world": "interrupted"}) == 1
|
||||
checks.append("uncertain interrupted request can safely be retried")
|
||||
|
||||
# Request-level tests cover stale versions, malformed JSON and valid recovery.
|
||||
active.request({"op": "edit", "world": "main", "expected_revision": 0,
|
||||
"operation_id": "stale", "changes": []}, success=False)
|
||||
active.process.stdin.write('{broken json}\n')
|
||||
active.process.stdin.flush()
|
||||
assert not json.loads(active.process.stdout.readline())["ok"]
|
||||
assert active.request({"op": "revision", "world": "main"}) == 80
|
||||
checks.append("invalid request does not terminate the local test session")
|
||||
active.request({"op": "create", "name": "uniform"})
|
||||
uniform = [{"pos": [x, y, z], "block": block}
|
||||
for y in range(16) for z in range(16) for x in range(16)]
|
||||
active.request({"op": "edit", "world": "uniform", "expected_revision": 0,
|
||||
"operation_id": "fill", "changes": uniform})
|
||||
active.close()
|
||||
active = Session(binary, directory)
|
||||
assert active.request({"op": "get", "world": "uniform", "pos": [8, 8, 8]}) == block
|
||||
compact = active.request({"op": "stats"})["storage"]
|
||||
assert compact["cache_entries"] == 1, compact
|
||||
assert compact["cache_payload_bytes"] == 20, compact
|
||||
checks.append("4096-cell uniform section occupies 20 encoded payload bytes in resident cache")
|
||||
return {"passed": True, "checks": checks, "final_metrics": active.request({"op": "stats"}),
|
||||
"limits": ["process crash tested; power loss and faulty hardware are not simulated",
|
||||
"storage-only workload; no Paper or gameplay performance comparison"]}
|
||||
finally:
|
||||
if active.process.poll() is None:
|
||||
active.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "target/debug/shacraft-tools")
|
||||
args = parser.parse_args()
|
||||
binary = args.binary.resolve()
|
||||
if not binary.is_file():
|
||||
parser.error("build first: cargo build -p shacraft-tools")
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-storage-check-") as directory:
|
||||
print(json.dumps(check(binary, Path(directory)), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
cargo build -p shacraft-tools --locked
|
||||
python3 scripts/check_storage.py
|
||||
|
||||
Reference in New Issue
Block a user