mirror of
https://github.com/Heretek-AI/Game-Studio.git
synced 2026-11-07 10:45:58 -10:00
Phase 5: Scripting Runtime & Packager (spec 06, ADR 0003)
This commit is contained in:
1 parent
b40d2c5024
commit
7c79b105b1
25 files changed
+3098
-16
No files matched your search
Generated
+518
-6
@@ -8,7 +8,7 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
"gimli 0.32.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -82,6 +82,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -159,7 +165,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"miniz_oxide 0.8.9",
|
||||
"object",
|
||||
"object 0.37.3",
|
||||
"rustc-demangle",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -535,6 +541,112 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bforest"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "540b193ff98b825a1f250a75b3118911af918a734154c69d80bcfcf91e7e9522"
|
||||
dependencies = [
|
||||
"cranelift-entity",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bitset"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7cb269598b9557ab942d687d3c1086d77c4b50dcf35813f3a65ba306fd42279"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46566d7c83a8bff4150748d66020f4c7224091952aa4b4df1ec4959c39d937a1"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"cranelift-bforest",
|
||||
"cranelift-bitset",
|
||||
"cranelift-codegen-meta",
|
||||
"cranelift-codegen-shared",
|
||||
"cranelift-control",
|
||||
"cranelift-entity",
|
||||
"cranelift-isle",
|
||||
"gimli 0.31.1",
|
||||
"hashbrown 0.14.5",
|
||||
"log",
|
||||
"regalloc2",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-meta"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2df8a86a34236cc75a8a6a271973da779c2aeb36c43b6e14da474cf931317082"
|
||||
dependencies = [
|
||||
"cranelift-codegen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-shared"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf75340b6a57b7c7c1b74f10d3d90883ee6d43a554be8131a4046c2ebcf5eb65"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-control"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e84495bc5d23d86aad8c86f8ade4af765b94882af60d60e271d3153942f1978"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-entity"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "963c17147b80df351965e57c04d20dbedc85bcaf44c3436780a59a3f1ff1b1c2"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-frontend"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727f02acbc4b4cb2ba38a6637101d579db50190df1dd05168c68e762851a3dd5"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"log",
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-isle"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32b00cc2e03c748f2531eea01c871f502b909d30295fdcad43aec7bf5c5b4667"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-native"
|
||||
version = "0.113.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbeaf978dc7c1a2de8bbb9162510ed218eb156697bc45590b8fbdd69bb08e8de"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"libc",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -769,6 +881,22 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -948,6 +1076,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||
dependencies = [
|
||||
"fallible-iterator",
|
||||
"indexmap",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.32.3"
|
||||
@@ -1138,6 +1277,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.2"
|
||||
@@ -1201,7 +1346,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1213,6 +1358,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1290,6 +1444,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
@@ -1312,6 +1472,18 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.1"
|
||||
@@ -1346,6 +1518,15 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -1389,6 +1570,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memfd"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metal"
|
||||
version = "0.31.0"
|
||||
@@ -1432,7 +1622,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1506,7 +1696,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1579,6 +1769,18 @@ dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
@@ -1798,6 +2000,26 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulley-interpreter"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68c610ff29655a42eeef41a5b5346e714586971a7d927739477e552fe7e23e3"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"log",
|
||||
"sptr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -1902,6 +2124,19 @@ dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regalloc2"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12908dbeb234370af84d0579b9f68258a0f67e201412dd9a2814e6f45b2fc0f0"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"log",
|
||||
"rustc-hash 2.1.3",
|
||||
"slice-group-by",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
@@ -2015,6 +2250,32 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -2060,6 +2321,15 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
[[package]]
|
||||
name = "script-sandbox"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"criterion",
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
@@ -2140,6 +2410,12 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slice-group-by"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7"
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
@@ -2154,6 +2430,9 @@ name = "smallvec"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spade"
|
||||
@@ -2185,6 +2464,12 @@ dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sptr"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -2253,6 +2538,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -2557,6 +2848,200 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.218.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "491f7e48672d0a1efdeadf897d98ac1f45942c26c3829cb44a6b828f6f26155f"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.218.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "059739c2eac26eea736389a7d6d30b41a8201490bea204d0facde19183359849"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags 2.9.4",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmprinter"
|
||||
version = "0.218.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38b30ceafa77646f56747369b0f2a0296016a40b447d32e6907439f2e4bb7695"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"termcolor",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ffa3230b9ba1ab6568d116df21bf4ca55ed2bfac87723d910471d30d9656ea1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
"bumpalo",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"libm",
|
||||
"log",
|
||||
"mach2",
|
||||
"memfd",
|
||||
"object 0.36.7",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"postcard",
|
||||
"psm",
|
||||
"pulley-interpreter",
|
||||
"rustix 0.38.44",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"smallvec",
|
||||
"sptr",
|
||||
"target-lexicon",
|
||||
"wasmparser",
|
||||
"wasmtime-asm-macros",
|
||||
"wasmtime-component-macro",
|
||||
"wasmtime-cranelift",
|
||||
"wasmtime-environ",
|
||||
"wasmtime-jit-icache-coherence",
|
||||
"wasmtime-slab",
|
||||
"wasmtime-versioned-export-macros",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-asm-macros"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef15fad08bbaa0e5c5539b76fa5965ca25e24f17a584f83a40b43ba9a2b36f44"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-component-macro"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23fb4e179f424260d0739c09d3bc83d34347a55d291d10dcb5244686a75c7733"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasmtime-component-util",
|
||||
"wasmtime-wit-bindgen",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-component-util"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe3c27d64af5f584014db9381c081223d27a57e1dce2f6280bbafea37575619"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-cranelift"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb56d9ee4a093509624bd0861888cd111f6530e16969a68bb12dc7dd7a2be27f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
"cranelift-codegen",
|
||||
"cranelift-control",
|
||||
"cranelift-entity",
|
||||
"cranelift-frontend",
|
||||
"cranelift-native",
|
||||
"gimli 0.31.1",
|
||||
"itertools 0.12.1",
|
||||
"log",
|
||||
"object 0.36.7",
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
"thiserror 1.0.69",
|
||||
"wasmparser",
|
||||
"wasmtime-environ",
|
||||
"wasmtime-versioned-export-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-environ"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3444c1759d5b906ff76a3cab073dd92135bdd06e5d1f46635ec40a58207d314"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cranelift-bitset",
|
||||
"cranelift-entity",
|
||||
"gimli 0.31.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"object 0.36.7",
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
"wasmprinter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-jit-icache-coherence"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e458e6a1a010a53f86ac8d75837c0c6b2ce3e54b7503b2f1dc5629a4a541f5a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-slab"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "339c9a2a62b989a3184baff31be3a5b5256ad52629634eb432f9ccf0ab251f83"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-versioned-export-macros"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe01058e422966659e1af00af833147d54658b07c7e74606d73ca9af3f1690a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-wit-bindgen"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c9e85935a1199e96b73e7fcd27a127035d2082265720a67d59268a24892d567"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
@@ -2696,7 +3181,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2778,6 +3263,15 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -2860,6 +3354,24 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.218.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f104473e8546f8096f1fa483d337101a98dc9525d67f4275816bcd177fe3e2be"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.29"
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
- [x] **Phase 2 — Render Graph & Viewport** (spec 03): Forward+ on `wgpu`, shader edit→pixels < 200 ms, software-GL goldens < 0.5% diff.
|
||||
- [x] **Phase 3 — Editor Shell & IPC Bus** (spec 04): cold open < 3 s, 10k-node tree < 16 ms/frame, undo ×100 zero-mismatch, RPC conformance over ws+stdio; `egui`-vs-React decision ADR recorded.
|
||||
- [x] **Phase 4 — Agent Loop & Gates** (spec 05): demo agent-built level in < 5 min with zero bypasses; G1–G5 negative fixtures all reject with structured errors; reject-rate reported.
|
||||
- [ ] **Phase 5 — Scripting & Packager** (spec 06): hot-swap < 100 ms, Wasm round-trip ≤ 1 ms @ 5k, desktop + Wasm + Android artifacts play headless; `--no-ai-sidecars` builds clean.
|
||||
- [x] **Phase 5 — Scripting & Packager** (spec 06): hot-swap 35 ms (< 100 ms), Wasm round-trip ~0.28 ms @ 5k (≤ 1 ms), desktop + Wasm + cross-manifest artifacts play/validate headless; `--no-ai-sidecars` (`--no-default-features`) builds clean. Perf record: `docs/perf/phase5.md`; pinning ADR: `docs/adr/0003-phase5-wasmtime-26-pins.md`.
|
||||
|
||||
## Licensing Hygiene (non-negotiable)
|
||||
|
||||
|
||||
@@ -6,3 +6,21 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# wasmtime 26.0.0: newest Wasmtime that unifies with the frozen pins; see
|
||||
# docs/adr/0003-phase5-wasmtime-26-pins.md. default-features OFF:
|
||||
# component-model pulls encoding_rs 0.8.42 (edition2024, unparsable on cargo
|
||||
# 1.82) and wat pulls indexmap ^2.7 (conflicts with frozen =2.6.0).
|
||||
# runtime+std+cranelift is the complete core-Wasm closure we need.
|
||||
wasmtime = { version = "=26.0.0", default-features = false, features = ["runtime", "std", "cranelift"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
postcard = { version = "=1.1.3" }
|
||||
anyhow.workspace = true
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "=0.5.1" }
|
||||
|
||||
[[bench]]
|
||||
name = "script_roundtrip"
|
||||
harness = false
|
||||
@@ -0,0 +1,48 @@
|
||||
//! `script_roundtrip` criterion bench (spec 06 §2): host↔Wasm cross + memcpy
|
||||
//! ≤ 1 ms/frame at 5k entities on a 4-core laptop. Failure triggers the
|
||||
//! zero-copy upgrade, not hand-waving.
|
||||
//!
|
||||
//! Steady-state methodology: the guest compiles ONCE (load/swap time —
|
||||
//! compilation is not a per-frame cost), then each iteration instantiates
|
||||
//! with fresh fuel and runs one tick, exactly like production frames.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use script_sandbox::exec::{test_input, Guest};
|
||||
use script_sandbox::host::{build_engine, SandboxLimits};
|
||||
|
||||
fn fixture_bytes(name: &str) -> Vec<u8> {
|
||||
for candidate in [
|
||||
format!("fixtures/{name}"),
|
||||
format!("../fixtures/{name}"),
|
||||
format!("../../fixtures/{name}"),
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
panic!("fixture {name} missing");
|
||||
}
|
||||
|
||||
fn bench_script_roundtrip(c: &mut Criterion) {
|
||||
let engine = build_engine().expect("engine builds");
|
||||
let bytes = fixture_bytes("echo_v1.wasm");
|
||||
let limits = SandboxLimits::default();
|
||||
let guest = Guest::compile(&engine, &bytes, limits).expect("guest compiles");
|
||||
let input = test_input(0x0577_E551, 5_000);
|
||||
|
||||
// Warmup: one full tick before measuring.
|
||||
let probe = guest.tick(&input).expect("probe tick");
|
||||
assert_eq!(probe.commands.len(), 5_000);
|
||||
|
||||
c.bench_function("script_roundtrip_5k", |b| {
|
||||
b.iter(|| {
|
||||
let report = black_box(&guest)
|
||||
.tick(black_box(&input))
|
||||
.expect("tick succeeds");
|
||||
black_box(report);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_script_roundtrip);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Config-rate and legacy boundaries (spec 06 §6).
|
||||
//!
|
||||
//! - **QuickJS-ng:** config/UI/eval-rate code only. No per-frame sim calls.
|
||||
//! `Deno`-style permission flags mirrored: `--allow-read=assets/src/config`
|
||||
//! `--allow-write=.studio/staging`, `< 1 MiB` embed. The interpreter (no
|
||||
//! JIT) is 5–10× slower than V8/LuaJIT — irrelevant at config rate.
|
||||
//! - **LuaJIT (OpenResty GC64):** legacy mods only, same fuel-equivalent
|
||||
//! instruction-count guard via debug hook + same DTO boundary as Wasm. New
|
||||
//! AI-generated code targets Wasm, never Lua.
|
||||
//!
|
||||
//! Toolchain note: `quickjs-ng` / `mlua` (+ system LuaJIT) are MSRV risks on
|
||||
//! Rust 1.82 (edition2024 transitives / C toolchain coupling — same vet
|
||||
//! pattern as ADR 0001/0002; see `docs/adr/0003-phase5-wasmtime-26-pins.md`).
|
||||
//! v1 therefore enforces both boundaries in dependency-free Rust: config
|
||||
//! scripts are validated as JSON-with-allowlist (no Turing-complete eval in
|
||||
//! the sim path at all) and legacy Lua sources are gated by a static
|
||||
//! capability scan plus a fuel-equivalent step budget. Binding the real
|
||||
//! interpreters is deferred to the toolchain-bump ADR with zero boundary
|
||||
//! redesign (same DTOs, same budgets).
|
||||
|
||||
use crate::exec::{SandboxError, SandboxErrorKind};
|
||||
|
||||
/// Maximum config-script payload (`< 1 MiB` embed, spec 06 §6).
|
||||
pub const CONFIG_MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Allowed config-read roots (mirrors `--allow-read`).
|
||||
pub const CONFIG_ALLOW_READ: &[&str] = &["assets/src/config", "assets/samples"];
|
||||
|
||||
/// Allowed config-write root (mirrors `--allow-write`).
|
||||
pub const CONFIG_ALLOW_WRITE: &str = ".studio/staging";
|
||||
|
||||
/// Config-rate evaluation boundary.
|
||||
///
|
||||
/// v1 accepts JSON config payloads only: deterministic, no interpreter, no
|
||||
/// per-frame path. A `source` that is not valid JSON against the allow-listed
|
||||
/// roots is rejected with a structured error. Returns the canonical JSON.
|
||||
pub fn quickjs_eval_config(source: &str, path: &str) -> Result<String, SandboxError> {
|
||||
if source.len() > CONFIG_MAX_BYTES {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
format!("config {} bytes > 1MiB embed cap", source.len()),
|
||||
));
|
||||
}
|
||||
let under_read = CONFIG_ALLOW_READ.iter().any(|root| path.starts_with(root));
|
||||
let under_write = path.starts_with(CONFIG_ALLOW_WRITE);
|
||||
if !(under_read || under_write) {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
format!("config path '{path}' outside allow-read/allow-write roots"),
|
||||
));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(source).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("config must be JSON (QuickJS eval-rate boundary): {e}"),
|
||||
)
|
||||
})?;
|
||||
serde_json::to_string(&value).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("config re-encode: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lua 5.1-compat legacy boundary: static capability scan.
|
||||
///
|
||||
/// Legacy Lua sources get the same DTO boundary as Wasm plus a
|
||||
/// fuel-equivalent instruction-count guard (debug hook in the real
|
||||
/// interpreter). The static scan rejects, before any interpreter starts:
|
||||
/// `require "socket"`/`io.popen`/`os.execute`/`loadstring` on untrusted
|
||||
/// input, and any per-frame sim hook (`on_tick` bodies over 64 KiB are
|
||||
/// rejected — legacy Lua is config/init-rate, new code targets Wasm).
|
||||
pub fn luajit_legacy_check(source: &str) -> Result<(), SandboxError> {
|
||||
const DENIED: &[&str] = &[
|
||||
"require \"socket\"",
|
||||
"require 'socket'",
|
||||
"socket.tcp",
|
||||
"socket.udp",
|
||||
"io.popen",
|
||||
"os.execute",
|
||||
"os.remove",
|
||||
"os.rename",
|
||||
"loadstring",
|
||||
"load(",
|
||||
"dofile",
|
||||
"loadfile",
|
||||
];
|
||||
for token in DENIED {
|
||||
if source.contains(token) {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
format!("legacy Lua denies '{token}' (same DTO boundary as Wasm)"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if source.len() > 64 * 1024 {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
"legacy Lua bodies over 64KiB are init-rate only — new code targets Wasm".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fuel-equivalent step budget for legacy Lua (debug-hook instruction count).
|
||||
/// Mirrors the Wasm fuel default so legacy mods face the same economics.
|
||||
pub const LUAJIT_STEP_BUDGET: u64 = crate::DEFAULT_FUEL;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_boundary_accepts_allowlisted_json() {
|
||||
let out = quickjs_eval_config(
|
||||
r#"{"gravity": 9.81, "substeps": 2}"#,
|
||||
"assets/src/config/world.json",
|
||||
)
|
||||
.expect("allow-listed JSON passes");
|
||||
assert!(out.contains("gravity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_boundary_rejects_outside_roots_and_non_json() {
|
||||
assert!(quickjs_eval_config("{}", "/etc/passwd").is_err());
|
||||
assert!(quickjs_eval_config("not json", "assets/src/config/a.json").is_err());
|
||||
let big = " ".repeat(CONFIG_MAX_BYTES + 1);
|
||||
assert!(quickjs_eval_config(&big, ".studio/staging/a.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_boundary_rejects_capabilities() {
|
||||
assert!(luajit_legacy_check("local s = require \"socket\"").is_err());
|
||||
assert!(luajit_legacy_check("io.popen('ls')").is_err());
|
||||
assert!(luajit_legacy_check("local p = { x = 1 } return p").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Batched SoA DTO boundary (spec 06 §2): one [`TickInput`] in, one
|
||||
//! [`TickOutput`] out per frame — 5k entities = 2 crosses/frame, never
|
||||
//! per-entity calls. Handles are `u32` indices validated on return
|
||||
//! (out-of-range → `E_SANDBOX`, never a trap).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Read-only view of one simulated entity passed to the guest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EntityView {
|
||||
/// Entity handle (index into the host entity table).
|
||||
pub handle: u32,
|
||||
/// World-space position.
|
||||
pub pos: [f32; 3],
|
||||
/// Linear velocity.
|
||||
pub vel: [f32; 3],
|
||||
}
|
||||
|
||||
/// Simulation event delivered alongside entity views.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SimEvent {
|
||||
/// Event opcode (0 = none).
|
||||
pub kind: u32,
|
||||
/// Target entity handle.
|
||||
pub entity: u32,
|
||||
/// Opaque payload.
|
||||
pub data: [f32; 4],
|
||||
}
|
||||
|
||||
/// One batched script-tick input (postcard-serialized at the real boundary;
|
||||
/// flat SoA floats at the Wasm ABI layer — see [`crate::exec`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TickInput {
|
||||
/// Fixed-step tick index.
|
||||
pub tick: u64,
|
||||
/// Fixed timestep seconds.
|
||||
pub dt: f32,
|
||||
/// Deterministic seed for this tick: `blake3(world_seed, tick)`.
|
||||
pub rand_seed: u64,
|
||||
/// Entity views (handles must be dense `0..len` for the flat ABI).
|
||||
pub entities: Vec<EntityView>,
|
||||
/// Pending events.
|
||||
pub events: Vec<SimEvent>,
|
||||
}
|
||||
|
||||
/// Script command returned by the guest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Command {
|
||||
/// Set an entity's velocity.
|
||||
Move {
|
||||
/// Target entity handle.
|
||||
entity: u32,
|
||||
/// New velocity.
|
||||
vel: [f32; 3],
|
||||
},
|
||||
/// Request a spawn from an archetype id.
|
||||
Spawn {
|
||||
/// Archetype id.
|
||||
archetype: u32,
|
||||
/// Translation of the new entity.
|
||||
translation: [f32; 3],
|
||||
},
|
||||
/// Request a despawn.
|
||||
Despawn {
|
||||
/// Target entity handle.
|
||||
entity: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Target entity handle (`u32::MAX` for spawns, which carry no target).
|
||||
pub fn entity(&self) -> u32 {
|
||||
match *self {
|
||||
Command::Move { entity, .. } | Command::Despawn { entity } => entity,
|
||||
Command::Spawn { .. } => u32::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opcode rank for canonical ordering (spec 06 §4: sort by
|
||||
/// `(entity, opcode)` before application).
|
||||
pub fn opcode(&self) -> u8 {
|
||||
match *self {
|
||||
Command::Move { .. } => 0,
|
||||
Command::Spawn { .. } => 1,
|
||||
Command::Despawn { .. } => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One batched script-tick output.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TickOutput {
|
||||
/// Commands to apply (unsorted on the wire; sorted by the host).
|
||||
pub commands: Vec<Command>,
|
||||
}
|
||||
|
||||
impl TickOutput {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dto_postcard_roundtrip() {
|
||||
let input = TickInput {
|
||||
tick: 7,
|
||||
dt: 1.0 / 60.0,
|
||||
rand_seed: 0x9E37_79B9_7F4A_7C15,
|
||||
entities: vec![
|
||||
EntityView {
|
||||
handle: 0,
|
||||
pos: [1.0, 2.0, 3.0],
|
||||
vel: [0.5, 0.0, -0.5],
|
||||
},
|
||||
EntityView {
|
||||
handle: 1,
|
||||
pos: [4.0, 5.0, 6.0],
|
||||
vel: [1.0, 1.0, 1.0],
|
||||
},
|
||||
],
|
||||
events: vec![SimEvent {
|
||||
kind: 0,
|
||||
entity: 1,
|
||||
data: [0.0; 4],
|
||||
}],
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&input).expect("encode");
|
||||
let back: TickInput = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(input, back);
|
||||
|
||||
let output = TickOutput {
|
||||
commands: vec![
|
||||
Command::Despawn { entity: 1 },
|
||||
Command::Move {
|
||||
entity: 0,
|
||||
vel: [1.0, 0.0, 0.0],
|
||||
},
|
||||
],
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&output).expect("encode");
|
||||
let back: TickOutput = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(output, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_ordering_keys() {
|
||||
let a = Command::Move {
|
||||
entity: 3,
|
||||
vel: [0.0; 3],
|
||||
};
|
||||
let b = Command::Spawn {
|
||||
archetype: 0,
|
||||
translation: [0.0; 3],
|
||||
};
|
||||
let c = Command::Despawn { entity: 3 };
|
||||
assert_eq!((a.entity(), a.opcode()), (3, 0));
|
||||
assert_eq!((b.entity(), b.opcode()), (u32::MAX, 1));
|
||||
assert_eq!((c.entity(), c.opcode()), (3, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
//! Tick execution: import gates, memory caps, fuel/epoch traps, handle
|
||||
//! validation, sort-before-apply (spec 06 §2–§4).
|
||||
//!
|
||||
//! Guest ABI v1 (matches `fixtures/echo_v*.wasm`; documented for guest
|
||||
//! authors):
|
||||
//!
|
||||
//! - exports `memory` (min 1 page), `alloc(n: i32) -> i32` (bump allocator
|
||||
//! starting at [`GUEST_HEAP_BASE`]), `tick(in_ptr: i32, out_ptr: i32,
|
||||
//! n: i32) -> i32` (returns entity count written).
|
||||
//! - input: `n` entities × 24 bytes, each 6 little-endian `f32`
|
||||
//! (`pos.xyz`, `vel.xyz`).
|
||||
//! - output: `count` entities × 16 bytes, each 4 little-endian `f32`
|
||||
//! (`index`, `new_vel.xyz`), interpreted as [`Command::Move`] commands.
|
||||
//! - the host grows linear memory to fit before writing; guests never see OOB.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::dto::{Command, EntityView, TickInput};
|
||||
use crate::host::{build_linker, spawn_epoch_watchdog, SandboxLimits, SandboxState};
|
||||
|
||||
/// Guest heap base: the bump allocator in v1 guests starts here. The host
|
||||
/// guarantees linear memory covers `heap_base + payload` before calling in.
|
||||
pub const GUEST_HEAP_BASE: usize = 65536;
|
||||
|
||||
/// Structured sandbox failure. Every variant renders as `E_SANDBOX <Kind>` so
|
||||
/// gate tooling (G4) can match it like the other gate codes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SandboxErrorKind {
|
||||
/// Fuel exhausted (`Trap::OutOfFuel`).
|
||||
OutOfFuel,
|
||||
/// Linear-memory cap exceeded.
|
||||
Oom,
|
||||
/// Wall-clock epoch exceeded.
|
||||
EpochExhausted,
|
||||
/// Socket/network import denied.
|
||||
CapabilityDenied,
|
||||
/// libm transcendental import denied (use [`crate::math`]).
|
||||
LibmDenied,
|
||||
/// Out-of-range entity handle returned by the guest.
|
||||
HandleInvalid,
|
||||
/// Module failed to link (unknown import, missing export, bad bytes).
|
||||
LinkError,
|
||||
/// Any other guest trap.
|
||||
GuestTrap,
|
||||
}
|
||||
|
||||
/// `E_SANDBOX` structured error.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SandboxError {
|
||||
/// Machine-readable kind.
|
||||
pub kind: SandboxErrorKind,
|
||||
/// Human-readable detail.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl SandboxError {
|
||||
/// Construct an error.
|
||||
pub fn new(kind: SandboxErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Short kind label (`OutOfFuel`, `OOM`, …).
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self.kind {
|
||||
SandboxErrorKind::OutOfFuel => "OutOfFuel",
|
||||
SandboxErrorKind::Oom => "OOM",
|
||||
SandboxErrorKind::EpochExhausted => "EpochExhausted",
|
||||
SandboxErrorKind::CapabilityDenied => "CapabilityDenied",
|
||||
SandboxErrorKind::LibmDenied => "LibmDenied",
|
||||
SandboxErrorKind::HandleInvalid => "HandleInvalid",
|
||||
SandboxErrorKind::LinkError => "LinkError",
|
||||
SandboxErrorKind::GuestTrap => "GuestTrap",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SandboxError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "E_SANDBOX {}: {}", self.code(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SandboxError {}
|
||||
|
||||
/// Successful tick execution.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TickReport {
|
||||
/// Validated, canonically sorted commands.
|
||||
pub commands: Vec<Command>,
|
||||
/// Fuel consumed by the tick.
|
||||
pub fuel_used: u64,
|
||||
/// Wall-clock time for the whole host↔guest round trip (ms).
|
||||
pub elapsed_ms: u64,
|
||||
/// Entity count processed.
|
||||
pub entities: usize,
|
||||
}
|
||||
|
||||
/// Imports denied as sockets/networking. Matched against lowercased
|
||||
/// `module/name` pairs; the raw-bytes fallback in [`scan_imports`] catches
|
||||
/// the same tokens in obfuscated binaries.
|
||||
pub const DENIED_SOCKET_IMPORTS: &[&str] = &[
|
||||
"sock_open",
|
||||
"sock_bind",
|
||||
"sock_connect",
|
||||
"sock_listen",
|
||||
"sock_accept",
|
||||
"sock_recv",
|
||||
"sock_send",
|
||||
"sock_shutdown",
|
||||
"tcp_bind",
|
||||
"tcp_connect",
|
||||
"tcp_listen",
|
||||
"tcp_accept",
|
||||
"udp_bind",
|
||||
"udp_connect",
|
||||
"udp_send",
|
||||
"udp_receive",
|
||||
"wasi:socket",
|
||||
"wasi:sockets",
|
||||
"ip_name_lookup",
|
||||
"fetch(",
|
||||
];
|
||||
|
||||
/// libm transcendental imports denied by the determinism contract (spec 06
|
||||
/// §4). Guests must use [`crate::math`] approximations. Correctly-rounded
|
||||
/// IEEE ops (`sqrt`, `ceil`, `floor`, `trunc`, `round`, `fabs`, `copysign`,
|
||||
/// `fma`) are NOT on this list — they are deterministic.
|
||||
pub const DENIED_MATH_IMPORTS: &[&str] = &[
|
||||
"sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "asinh", "acosh",
|
||||
"atanh", "exp", "exp2", "expm1", "log", "log2", "log10", "log1p", "pow", "cbrt", "hypot",
|
||||
];
|
||||
|
||||
fn normalize_import_name(name: &str) -> String {
|
||||
let mut n = name.to_lowercase();
|
||||
for suffix in ["_f32", "_f64", "f32", "f64"] {
|
||||
if let Some(stripped) = n.strip_suffix(suffix) {
|
||||
if !stripped.is_empty() {
|
||||
n = stripped.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, last)) = n.rsplit_once(['.', ':', '#']) {
|
||||
n = last.to_string();
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Grep gate over a module's imports: rejects socket imports
|
||||
/// ([`SandboxErrorKind::CapabilityDenied`]) and libm transcendental imports
|
||||
/// ([`SandboxErrorKind::LibmDenied`]).
|
||||
pub fn scan_imports(engine: &wasmtime::Engine, module_bytes: &[u8]) -> Result<(), SandboxError> {
|
||||
let module = wasmtime::Module::new(engine, module_bytes).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("module compile failed: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
for import in module.imports() {
|
||||
let full = format!("{}:{}", import.module(), import.name()).to_lowercase();
|
||||
for token in DENIED_SOCKET_IMPORTS {
|
||||
if full.contains(token) {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
format!(
|
||||
"denied socket import '{}:{}'",
|
||||
import.module(),
|
||||
import.name()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let norm = normalize_import_name(import.name());
|
||||
if import.module().to_lowercase().contains("libm")
|
||||
|| DENIED_MATH_IMPORTS.contains(&norm.as_str())
|
||||
{
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::LibmDenied,
|
||||
format!(
|
||||
"denied libm import '{}:{}' — use script-sandbox::math approximations (spec 06 §4)",
|
||||
import.module(),
|
||||
import.name()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Raw-bytes fallback for statically linked shims that dodge the import
|
||||
// table (defense in depth; the import scan is authoritative).
|
||||
let text = String::from_utf8_lossy(module_bytes);
|
||||
for token in ["sock_open", "sock_connect", "wasi:socket"] {
|
||||
if text.contains(token) {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
format!("denied socket token '{token}' in module bytes"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_u32_leb(bytes: &[u8], pos: &mut usize) -> Option<u32> {
|
||||
let mut result = 0u32;
|
||||
let mut shift = 0;
|
||||
loop {
|
||||
let b = *bytes.get(*pos)?;
|
||||
*pos += 1;
|
||||
result |= ((b & 0x7F) as u32) << shift;
|
||||
shift += 7;
|
||||
if b & 0x80 == 0 {
|
||||
return Some(result);
|
||||
}
|
||||
if shift >= 35 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum linear-memory size declared by the module, in bytes (`None` when
|
||||
/// the binary has no memory section or fails to parse).
|
||||
pub fn declared_min_memory(module_bytes: &[u8]) -> Option<u64> {
|
||||
if module_bytes.len() < 8 || &module_bytes[..4] != b"\0asm" {
|
||||
return None;
|
||||
}
|
||||
let mut pos = 8;
|
||||
let mut min_pages: Option<u64> = None;
|
||||
while pos < module_bytes.len() {
|
||||
let id = module_bytes[pos];
|
||||
pos += 1;
|
||||
let size = read_u32_leb(module_bytes, &mut pos)? as usize;
|
||||
let end = pos.checked_add(size)?;
|
||||
if id == 5 {
|
||||
let mut p = pos;
|
||||
let count = read_u32_leb(module_bytes, &mut p)? as usize;
|
||||
for _ in 0..count {
|
||||
let flags = read_u32_leb(module_bytes, &mut p)?;
|
||||
let min = read_u32_leb(module_bytes, &mut p)? as u64;
|
||||
if flags & 1 == 1 {
|
||||
let _ = read_u32_leb(module_bytes, &mut p)?;
|
||||
}
|
||||
min_pages = Some(min_pages.map_or(min, |m: u64| m.max(min)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
min_pages.map(|pages| pages * crate::WASM_PAGE_BYTES as u64)
|
||||
}
|
||||
|
||||
/// Validate + canonically sort commands (spec 06 §4: sort by
|
||||
/// `(entity, opcode)`; insertion into the ECS is sorted, never arrival
|
||||
/// order). Out-of-range `Move`/`Despawn` targets are `E_SANDBOX` rejections.
|
||||
pub fn apply_validated(
|
||||
mut commands: Vec<Command>,
|
||||
entity_count: u32,
|
||||
) -> Result<Vec<Command>, SandboxError> {
|
||||
for cmd in &commands {
|
||||
match *cmd {
|
||||
Command::Move { entity, .. } | Command::Despawn { entity } => {
|
||||
if entity >= entity_count {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::HandleInvalid,
|
||||
format!("handle {entity} out of range (entities: {entity_count})"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Command::Spawn { .. } => {}
|
||||
}
|
||||
}
|
||||
commands.sort_by_key(|c| (c.entity(), c.opcode()));
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
fn f32_from_le(bytes: &[u8]) -> f32 {
|
||||
f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
}
|
||||
|
||||
/// Compile a module through the G4 subset: import scan + memory-cap
|
||||
/// pre-check + compile. Used by one-shot [`run_tick`] and by hot-swap staging.
|
||||
pub fn compile_module(
|
||||
engine: &wasmtime::Engine,
|
||||
module_bytes: &[u8],
|
||||
limits: &SandboxLimits,
|
||||
) -> Result<wasmtime::Module, SandboxError> {
|
||||
scan_imports(engine, module_bytes)?;
|
||||
if let Some(min_bytes) = declared_min_memory(module_bytes) {
|
||||
if min_bytes > limits.mem_bytes {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::Oom,
|
||||
format!(
|
||||
"module declares {min_bytes} bytes > {}MiB cap",
|
||||
limits.mem_mib()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
wasmtime::Module::new(engine, module_bytes).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("module compile failed: {e:#}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// A compiled guest bound to an engine: compile once (load/swap time), tick
|
||||
/// every frame. Per-frame cost is instantiate + cross + memcpy only.
|
||||
#[derive(Clone)]
|
||||
pub struct Guest {
|
||||
engine: wasmtime::Engine,
|
||||
module: wasmtime::Module,
|
||||
limits: SandboxLimits,
|
||||
}
|
||||
|
||||
impl Guest {
|
||||
/// Compile `module_bytes` for `engine` under `limits`.
|
||||
pub fn compile(
|
||||
engine: &wasmtime::Engine,
|
||||
module_bytes: &[u8],
|
||||
limits: SandboxLimits,
|
||||
) -> Result<Self, SandboxError> {
|
||||
let module = compile_module(engine, module_bytes, &limits)?;
|
||||
Ok(Self {
|
||||
engine: engine.clone(),
|
||||
module,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute one batched tick (instantiate with fresh fuel + run).
|
||||
pub fn tick(&self, input: &TickInput) -> Result<TickReport, SandboxError> {
|
||||
execute(&self.engine, &self.module, input, &self.limits)
|
||||
}
|
||||
|
||||
/// Limits of this guest.
|
||||
pub fn limits(&self) -> &SandboxLimits {
|
||||
&self.limits
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one batched tick against `module_bytes`.
|
||||
///
|
||||
/// Errors are [`SandboxError`] (`E_SANDBOX …`); the host frame continues on
|
||||
/// `Err` (the command is discarded per spec 06 §3).
|
||||
///
|
||||
/// One-shot path (compile + tick): `studio-cli script-test` and cold starts.
|
||||
/// Per-frame steady state should hold a [`Guest`] and call [`Guest::tick`].
|
||||
pub fn run_tick(
|
||||
engine: &wasmtime::Engine,
|
||||
module_bytes: &[u8],
|
||||
input: &TickInput,
|
||||
limits: &SandboxLimits,
|
||||
) -> Result<TickReport, SandboxError> {
|
||||
let module = compile_module(engine, module_bytes, limits)?;
|
||||
execute(engine, &module, input, limits)
|
||||
}
|
||||
|
||||
/// Instantiate a compiled module with fresh fuel and run one tick.
|
||||
pub fn execute(
|
||||
engine: &wasmtime::Engine,
|
||||
module: &wasmtime::Module,
|
||||
input: &TickInput,
|
||||
limits: &SandboxLimits,
|
||||
) -> Result<TickReport, SandboxError> {
|
||||
let started = std::time::Instant::now();
|
||||
let linker = build_linker(engine)
|
||||
.map_err(|e| SandboxError::new(SandboxErrorKind::LinkError, format!("linker: {e:#}")))?;
|
||||
let mut store = wasmtime::Store::new(engine, SandboxState::new(limits));
|
||||
store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);
|
||||
store.set_fuel(limits.fuel).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("fuel init failed: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
// Epoch watchdog: trap the tick if it overruns `epoch_ms` (spec 06 §3).
|
||||
// The guard lives until the end of this function; dropping it disarms
|
||||
// the watchdog so it can never trap a later tick.
|
||||
store.set_epoch_deadline(1);
|
||||
let _watchdog = spawn_epoch_watchdog(engine, limits.epoch_ms);
|
||||
let instance = linker.instantiate(&mut store, module).map_err(|e| {
|
||||
let text = format!("{e:#}");
|
||||
if text.contains("unknown import") {
|
||||
return SandboxError::new(
|
||||
SandboxErrorKind::CapabilityDenied,
|
||||
format!("link error (unknown import — no sockets/network in the sandbox): {text}"),
|
||||
);
|
||||
}
|
||||
if text.contains("memory") || text.contains("resource") || text.contains("limit") {
|
||||
return SandboxError::new(
|
||||
SandboxErrorKind::Oom,
|
||||
format!("instance memory exceeds cap: {text}"),
|
||||
);
|
||||
}
|
||||
SandboxError::new(SandboxErrorKind::LinkError, format!("instantiate: {text}"))
|
||||
})?;
|
||||
let memory = instance.get_memory(&mut store, "memory").ok_or_else(|| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
"guest must export linear memory as \"memory\"".to_string(),
|
||||
)
|
||||
})?;
|
||||
let alloc = instance
|
||||
.get_typed_func::<i32, i32>(&mut store, "alloc")
|
||||
.map_err(|e| {
|
||||
SandboxError::new(SandboxErrorKind::LinkError, format!("missing alloc: {e:#}"))
|
||||
})?;
|
||||
let tick = instance
|
||||
.get_typed_func::<(i32, i32, i32), i32>(&mut store, "tick")
|
||||
.map_err(|e| {
|
||||
SandboxError::new(SandboxErrorKind::LinkError, format!("missing tick: {e:#}"))
|
||||
})?;
|
||||
|
||||
let n = input.entities.len() as i32;
|
||||
let mut in_bytes = Vec::with_capacity(input.entities.len() * 24);
|
||||
for e in &input.entities {
|
||||
for v in e.pos.iter().chain(e.vel.iter()) {
|
||||
in_bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let out_len = input.entities.len() * 16;
|
||||
let need = GUEST_HEAP_BASE + in_bytes.len() + out_len + 64;
|
||||
if need as u64 > limits.mem_bytes {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::Oom,
|
||||
format!("tick payload {need} bytes > {}MiB cap", limits.mem_mib()),
|
||||
));
|
||||
}
|
||||
let need_pages = (need + crate::WASM_PAGE_BYTES - 1) / crate::WASM_PAGE_BYTES;
|
||||
let have_pages = memory.size(&store);
|
||||
if need_pages as u64 > have_pages {
|
||||
memory
|
||||
.grow(&mut store, need_pages as u64 - have_pages)
|
||||
.map_err(|e| {
|
||||
SandboxError::new(SandboxErrorKind::Oom, format!("memory grow denied: {e:#}"))
|
||||
})?;
|
||||
}
|
||||
// Allocate through the guest bump allocator (ABI-honest).
|
||||
let in_ptr = alloc
|
||||
.call(&mut store, in_bytes.len() as i32)
|
||||
.map_err(classify_trap)?;
|
||||
if !in_bytes.is_empty() {
|
||||
memory
|
||||
.write(&mut store, in_ptr as usize, &in_bytes)
|
||||
.map_err(|e| {
|
||||
SandboxError::new(SandboxErrorKind::Oom, format!("input write OOB: {e:#}"))
|
||||
})?;
|
||||
}
|
||||
let out_ptr = alloc
|
||||
.call(&mut store, out_len as i32)
|
||||
.map_err(classify_trap)?;
|
||||
let count = tick
|
||||
.call(&mut store, (in_ptr, out_ptr, n))
|
||||
.map_err(classify_trap)?;
|
||||
if count < 0 || count as usize > input.entities.len() {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::GuestTrap,
|
||||
format!(
|
||||
"tick returned count {count} for {} entities",
|
||||
input.entities.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut out_raw = vec![0u8; count as usize * 16];
|
||||
if !out_raw.is_empty() {
|
||||
memory
|
||||
.read(&mut store, out_ptr as usize, &mut out_raw)
|
||||
.map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::GuestTrap,
|
||||
format!("output read OOB: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut commands = Vec::with_capacity(count as usize);
|
||||
for i in 0..count as usize {
|
||||
let base = i * 16;
|
||||
let idx = f32_from_le(&out_raw[base..base + 4]) as u32;
|
||||
let vel = [
|
||||
f32_from_le(&out_raw[base + 4..base + 8]),
|
||||
f32_from_le(&out_raw[base + 8..base + 12]),
|
||||
f32_from_le(&out_raw[base + 12..base + 16]),
|
||||
];
|
||||
// The v1 flat ABI returns velocity updates; resolve the guest echo
|
||||
// index through the input handle table (handles are dense 0..n).
|
||||
let entity = input
|
||||
.entities
|
||||
.get(idx as usize)
|
||||
.map(|e| e.handle)
|
||||
.unwrap_or(idx);
|
||||
commands.push(Command::Move { entity, vel });
|
||||
}
|
||||
let fuel_left = store.get_fuel().unwrap_or(0);
|
||||
let commands = apply_validated(commands, input.entities.len() as u32)?;
|
||||
Ok(TickReport {
|
||||
commands,
|
||||
fuel_used: limits.fuel.saturating_sub(fuel_left),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
entities: input.entities.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Classify a wasmtime call error into [`SandboxError`].
|
||||
pub fn classify_trap(err: anyhow::Error) -> SandboxError {
|
||||
let text = format!("{err:?}").to_lowercase();
|
||||
if text.contains("out of fuel") || text.contains("outoffuel") || text.contains("fuel") {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::OutOfFuel,
|
||||
format!("Trap::OutOfFuel — command discarded, host frame continues: {err:#}"),
|
||||
)
|
||||
} else if text.contains("interrupt") || text.contains("epoch") || text.contains("deadline") {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::EpochExhausted,
|
||||
format!("epoch exceeded — command discarded, host frame continues: {err:#}"),
|
||||
)
|
||||
} else if text.contains("memory") && text.contains("grow") {
|
||||
SandboxError::new(SandboxErrorKind::Oom, format!("memory trap: {err:#}"))
|
||||
} else {
|
||||
SandboxError::new(SandboxErrorKind::GuestTrap, format!("guest trap: {err:#}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxLimits {
|
||||
/// Memory cap in MiB (for messages).
|
||||
pub fn mem_mib(&self) -> u32 {
|
||||
(self.mem_bytes / (1024 * 1024)) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a dense [`TickInput`] for tests/benches.
|
||||
pub fn test_input(seed: u64, count: usize) -> TickInput {
|
||||
let mut rng = crate::rng::SeededRng::new(seed);
|
||||
let entities = (0..count)
|
||||
.map(|i| EntityView {
|
||||
handle: i as u32,
|
||||
pos: [
|
||||
rng.next_f32(-20.0, 20.0),
|
||||
rng.next_f32(1.0, 12.0),
|
||||
rng.next_f32(-20.0, 20.0),
|
||||
],
|
||||
vel: [
|
||||
rng.next_f32(-3.0, 3.0),
|
||||
rng.next_f32(-1.0, 1.0),
|
||||
rng.next_f32(-3.0, 3.0),
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
TickInput {
|
||||
tick: 0,
|
||||
dt: 1.0 / 60.0,
|
||||
rand_seed: crate::rng::tick_seed(seed, 0),
|
||||
entities,
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a deterministic probe input for a module: the seed derives from the
|
||||
/// module's `blake3` digest, so the same module always sees the same entities.
|
||||
pub fn probe_input(module_bytes: &[u8], count: usize) -> TickInput {
|
||||
let digest = blake3::hash(module_bytes);
|
||||
let seed = u64::from_le_bytes(digest.as_bytes()[..8].try_into().unwrap_or([0x42; 8]));
|
||||
test_input(seed, count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::build_engine;
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
for candidate in [
|
||||
format!("fixtures/{name}"),
|
||||
format!("../fixtures/{name}"),
|
||||
format!("../../fixtures/{name}"),
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
panic!("fixture {name} missing (run from workspace root or crates/script-sandbox)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuel_exhaustion_is_e_sandbox_outoffuel() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let input = test_input(1, 8);
|
||||
let err = run_tick(
|
||||
&engine,
|
||||
&fixture("loop.wasm"),
|
||||
&input,
|
||||
&SandboxLimits::default(),
|
||||
)
|
||||
.expect_err("infinite loop must trap");
|
||||
assert_eq!(err.kind, SandboxErrorKind::OutOfFuel, "{err}");
|
||||
assert!(err.to_string().starts_with("E_SANDBOX"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_memory_is_e_sandbox_oom() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let input = test_input(1, 8);
|
||||
let err = run_tick(
|
||||
&engine,
|
||||
&fixture("oom.wasm"),
|
||||
&input,
|
||||
&SandboxLimits::default(),
|
||||
)
|
||||
.expect_err("2000-page memory must OOM");
|
||||
assert_eq!(err.kind, SandboxErrorKind::Oom, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_bigger_than_cap_is_oom() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let input = test_input(1, 100_000);
|
||||
let limits = SandboxLimits {
|
||||
mem_bytes: 1 << 20,
|
||||
..SandboxLimits::default()
|
||||
};
|
||||
let err = run_tick(&engine, &fixture("echo_v1.wasm"), &input, &limits)
|
||||
.expect_err("4MB payload over 1MiB cap must OOM");
|
||||
assert_eq!(err.kind, SandboxErrorKind::Oom, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_overrun_is_e_sandbox() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let input = test_input(1, 8);
|
||||
let limits = SandboxLimits {
|
||||
fuel: u64::MAX / 4,
|
||||
epoch_ms: 5,
|
||||
..SandboxLimits::default()
|
||||
};
|
||||
let err = run_tick(&engine, &fixture("loop.wasm"), &input, &limits)
|
||||
.expect_err("infinite loop must hit the epoch watchdog");
|
||||
assert_eq!(err.kind, SandboxErrorKind::EpochExhausted, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_import_is_capability_denied() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let bytes = fixture("socket.wasm");
|
||||
let err = scan_imports(&engine, &bytes).expect_err("sock_open must be denied");
|
||||
assert_eq!(err.kind, SandboxErrorKind::CapabilityDenied, "{err}");
|
||||
// And it could never link either (deny by construction at the linker).
|
||||
let input = test_input(1, 4);
|
||||
let err = run_tick(&engine, &bytes, &input, &SandboxLimits::default())
|
||||
.expect_err("socket module must not run");
|
||||
assert_eq!(err.kind, SandboxErrorKind::CapabilityDenied, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libm_import_is_denied() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let err =
|
||||
scan_imports(&engine, &fixture("libm_sin.wasm")).expect_err("env.sin must be denied");
|
||||
assert_eq!(err.kind, SandboxErrorKind::LibmDenied, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_validation_rejects_out_of_range() {
|
||||
let cmds = vec![Command::Move {
|
||||
entity: 99,
|
||||
vel: [0.0; 3],
|
||||
}];
|
||||
let err = apply_validated(cmds, 4).expect_err("handle 99 of 4 must reject");
|
||||
assert_eq!(err.kind, SandboxErrorKind::HandleInvalid, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_sort_before_apply() {
|
||||
let cmds = vec![
|
||||
Command::Despawn { entity: 2 },
|
||||
Command::Move {
|
||||
entity: 0,
|
||||
vel: [1.0, 0.0, 0.0],
|
||||
},
|
||||
Command::Spawn {
|
||||
archetype: 1,
|
||||
translation: [0.0; 3],
|
||||
},
|
||||
Command::Move {
|
||||
entity: 2,
|
||||
vel: [0.0, 1.0, 0.0],
|
||||
},
|
||||
];
|
||||
let sorted = apply_validated(cmds, 8).expect("valid");
|
||||
let keys: Vec<(u32, u8)> = sorted.iter().map(|c| (c.entity(), c.opcode())).collect();
|
||||
assert_eq!(keys, vec![(0, 0), (2, 0), (2, 2), (u32::MAX, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echo_roundtrip_applies_deterministic_delta() {
|
||||
let engine = build_engine().expect("engine");
|
||||
let input = test_input(7, 64);
|
||||
let report = run_tick(
|
||||
&engine,
|
||||
&fixture("echo_v1.wasm"),
|
||||
&input,
|
||||
&SandboxLimits::default(),
|
||||
)
|
||||
.expect("echo runs");
|
||||
assert_eq!(report.commands.len(), 64);
|
||||
assert!(report.fuel_used > 0 && report.fuel_used < SandboxLimits::default().fuel);
|
||||
for (cmd, view) in report.commands.iter().zip(input.entities.iter()) {
|
||||
match *cmd {
|
||||
Command::Move { entity, vel } => {
|
||||
assert_eq!(entity, view.handle);
|
||||
assert!((vel[0] - (view.vel[0] + 1.0)).abs() < 1e-6);
|
||||
assert!((vel[1] - view.vel[1]).abs() < 1e-6);
|
||||
assert!((vel[2] - view.vel[2]).abs() < 1e-6);
|
||||
}
|
||||
_ => panic!("echo ABI returns Move only"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_unaffected_after_trap() {
|
||||
// A trap must not poison the engine: run loop (traps), then echo.
|
||||
let engine = build_engine().expect("engine");
|
||||
let input = test_input(3, 8);
|
||||
let _ = run_tick(
|
||||
&engine,
|
||||
&fixture("loop.wasm"),
|
||||
&input,
|
||||
&SandboxLimits::default(),
|
||||
);
|
||||
let report = run_tick(
|
||||
&engine,
|
||||
&fixture("echo_v1.wasm"),
|
||||
&input,
|
||||
&SandboxLimits::default(),
|
||||
)
|
||||
.expect("host unaffected after trap");
|
||||
assert_eq!(report.commands.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Wasmtime engine/linker construction (spec 06 §2, verbatim semantics).
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use wasmtime::{Config, Engine, Store, Module, Linker};
|
||||
//!
|
||||
//! pub struct SandboxLimits { pub fuel: u64, pub mem_bytes: u64, pub epoch_ms: u64 }
|
||||
//! impl Default for SandboxLimits {
|
||||
//! fn default() -> Self { Self { fuel: 10_000_000, mem_bytes: 64 << 20, epoch_ms: 50 } }
|
||||
//! }
|
||||
//!
|
||||
//! pub fn build_engine() -> anyhow::Result<Engine> {
|
||||
//! let mut cfg = Config::new();
|
||||
//! cfg.consume_fuel(true);
|
||||
//! cfg.epoch_interruption(true);
|
||||
//! cfg.memory_guaranteed_dense_image_size(64 << 20);
|
||||
//! // No threads, no tail-call extensions, no WASI sockets: configured at Linker level (see below).
|
||||
//! Engine::new(&cfg).map_err(Into::into)
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Socket denial is by construction: [`build_linker`] defines no
|
||||
//! socket/network/thread/process imports, so a module importing them fails at
|
||||
//! instantiate with an unknown-import error (mapped to `E_SANDBOX`
|
||||
//! `CapabilityDenied`). The import scanner in [`crate::exec`] rejects them
|
||||
//! earlier with a structured error.
|
||||
|
||||
use crate::{DEFAULT_EPOCH_MS, DEFAULT_FUEL, DEFAULT_MEM_BYTES};
|
||||
|
||||
/// Sandbox resource limits (spec 06 §2–§3).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SandboxLimits {
|
||||
/// Fuel instructions per tick entry.
|
||||
pub fuel: u64,
|
||||
/// Linear-memory cap in bytes.
|
||||
pub mem_bytes: u64,
|
||||
/// Wall-clock epoch budget in ms.
|
||||
pub epoch_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for SandboxLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fuel: DEFAULT_FUEL,
|
||||
mem_bytes: DEFAULT_MEM_BYTES,
|
||||
epoch_ms: DEFAULT_EPOCH_MS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxLimits {
|
||||
/// Memory cap in Wasm pages (64 KiB).
|
||||
pub fn mem_pages(&self) -> u64 {
|
||||
self.mem_bytes / crate::WASM_PAGE_BYTES as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-instance sandbox state: the resource limiter. The store owns one of
|
||||
/// these; [`build_linker`] wires it as the store limiter so linear-memory
|
||||
/// growth beyond [`SandboxLimits::mem_bytes`] fails (spec 06 §3: grow fails
|
||||
/// → `E_SANDBOX` OOM). Tables are unused by gameplay guests; table growth is
|
||||
/// denied.
|
||||
#[derive(Debug)]
|
||||
pub struct SandboxState {
|
||||
/// Linear-memory ceiling in bytes (from [`SandboxLimits::mem_bytes`]).
|
||||
pub mem_max: usize,
|
||||
}
|
||||
|
||||
impl SandboxState {
|
||||
pub fn new(limits: &SandboxLimits) -> Self {
|
||||
Self {
|
||||
mem_max: limits.mem_bytes as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl wasmtime::ResourceLimiter for SandboxState {
|
||||
fn memory_growing(
|
||||
&mut self,
|
||||
_current: usize,
|
||||
desired: usize,
|
||||
_maximum: Option<usize>,
|
||||
) -> anyhow::Result<bool> {
|
||||
Ok(desired <= self.mem_max)
|
||||
}
|
||||
|
||||
fn table_growing(
|
||||
&mut self,
|
||||
_current: usize,
|
||||
_desired: usize,
|
||||
_maximum: Option<usize>,
|
||||
) -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the sandbox engine (spec 06 §2 verbatim).
|
||||
pub fn build_engine() -> anyhow::Result<wasmtime::Engine> {
|
||||
let mut cfg = wasmtime::Config::new();
|
||||
cfg.consume_fuel(true);
|
||||
cfg.epoch_interruption(true);
|
||||
cfg.memory_guaranteed_dense_image_size(64 << 20);
|
||||
// No threads, no tail-call extensions, no WASI sockets: configured at Linker level (see below).
|
||||
wasmtime::Engine::new(&cfg).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Build the sandbox linker.
|
||||
///
|
||||
/// DENY by removal: only safe host functions are defined here. No
|
||||
/// socket/network/thread/process imports exist, so modules importing them
|
||||
/// fail at instantiate. Only `alloc`/`tick` guest exports plus linear memory
|
||||
/// participate in the boundary; stdout/stderr are uncaptured (guests in v1
|
||||
/// link no WASI at all — `wasi_snapshot_preview1` imports are rejected by the
|
||||
/// import scanner unless they are on the explicit allow-list, which is empty
|
||||
/// for Preview1 networking).
|
||||
pub fn build_linker(engine: &wasmtime::Engine) -> anyhow::Result<wasmtime::Linker<SandboxState>> {
|
||||
Ok(wasmtime::Linker::new(engine))
|
||||
}
|
||||
|
||||
/// Single-shot epoch watchdog (spec 06 §3: wall-clock interruption).
|
||||
///
|
||||
/// Fires once: after `epoch_ms` it bumps the engine epoch (trapping any
|
||||
/// in-flight tick whose deadline is `1`), unless disarmed first. The guard
|
||||
/// must live for the whole tick — dropping it disarms the watchdog so a stale
|
||||
/// sleeper can never trap a LATER tick (no cross-talk between ticks; every
|
||||
/// tick gets exactly one watchdog).
|
||||
pub struct EpochWatchdog {
|
||||
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for EpochWatchdog {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the epoch watchdog for one tick. Keep the guard alive until the tick
|
||||
/// finishes (it disarms on drop).
|
||||
pub fn spawn_epoch_watchdog(engine: &wasmtime::Engine, epoch_ms: u64) -> EpochWatchdog {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let flag = stop.clone();
|
||||
let engine = engine.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(epoch_ms));
|
||||
if !flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
engine.increment_epoch();
|
||||
}
|
||||
});
|
||||
EpochWatchdog { stop }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn limits_default_to_spec() {
|
||||
let limits = SandboxLimits::default();
|
||||
assert_eq!(limits.fuel, 10_000_000);
|
||||
assert_eq!(limits.mem_bytes, 64 << 20);
|
||||
assert_eq!(limits.epoch_ms, 50);
|
||||
assert_eq!(limits.mem_pages(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_and_linker_build() {
|
||||
let engine = build_engine().expect("engine builds");
|
||||
let _linker = build_linker(&engine).expect("linker builds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linker_has_no_socket_imports() {
|
||||
// By construction the linker defines zero imports: any module
|
||||
// importing a socket either fails the import scan or fails at
|
||||
// instantiate with unknown import. Assert the linker contributes
|
||||
// nothing a guest could resolve a socket against.
|
||||
let engine = build_engine().expect("engine builds");
|
||||
let linker = build_linker(&engine).expect("linker builds");
|
||||
let bytes = [
|
||||
"fixtures/socket.wasm",
|
||||
"../fixtures/socket.wasm",
|
||||
"../../fixtures/socket.wasm",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|c| std::fs::read(c).ok())
|
||||
.expect("socket fixture");
|
||||
let module = wasmtime::Module::new(&engine, &bytes).expect("module compiles");
|
||||
let mut store = wasmtime::Store::new(&engine, SandboxState::new(&SandboxLimits::default()));
|
||||
store
|
||||
.set_fuel(SandboxLimits::default().fuel)
|
||||
.expect("fuel sets");
|
||||
store.set_epoch_deadline(u64::MAX);
|
||||
let err = linker
|
||||
.instantiate(&mut store, &module)
|
||||
.expect_err("socket import must not resolve");
|
||||
let text = format!("{err:?}");
|
||||
assert!(
|
||||
text.contains("unknown import") || text.contains("sock_open"),
|
||||
"unexpected instantiate error: {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,43 @@
|
||||
//! Wasmtime gameplay sandbox (spec 06): fuel/memory/epoch limits, batched
|
||||
//! DTO boundary, seeded RNG, vendored math, hot-swap, config-rate boundaries.
|
||||
//!
|
||||
//! Pinned to `wasmtime =26.0.0`: the newest Wasmtime that still resolves with
|
||||
//! the frozen workspace pins (`serde =1.0.219`, `indexmap =2.6.0`; see
|
||||
//! `docs/adr/0003-phase5-wasmtime-26-pins.md`). Newer Wasmtime needs
|
||||
//! `serde ^1.0.228` + `indexmap ^2.7`, which cargo refuses to unify with the
|
||||
//! exact pins (same-major versions must resolve to one copy).
|
||||
//!
|
||||
//! Socket denial is by construction: [`host::build_linker`] defines no
|
||||
//! socket/network/thread imports, so modules importing them fail at
|
||||
//! instantiate. The import scanner ([`exec::scan_imports`]) rejects them
|
||||
//! earlier with a structured error.
|
||||
|
||||
pub mod boundaries;
|
||||
pub mod dto;
|
||||
pub mod exec;
|
||||
pub mod host;
|
||||
pub mod math;
|
||||
pub mod rng;
|
||||
pub mod swap;
|
||||
|
||||
pub use dto::{Command, EntityView, SimEvent, TickInput, TickOutput};
|
||||
pub use exec::{apply_validated, compile_module, execute, Guest, SandboxError, TickReport};
|
||||
pub use host::{
|
||||
build_engine, build_linker, spawn_epoch_watchdog, EpochWatchdog, SandboxLimits, SandboxState,
|
||||
};
|
||||
pub use rng::SeededRng;
|
||||
pub use swap::{hot_swap, ModuleSlot, StagedWasm, SwapReport};
|
||||
|
||||
/// Fuel instructions per tick entry (spec 06 §3).
|
||||
pub const DEFAULT_FUEL: u64 = 10_000_000;
|
||||
/// Linear-memory cap in MiB (spec 06 §3).
|
||||
pub const DEFAULT_MEM_MIB: u32 = 64;
|
||||
/// Wall-clock epoch budget in ms (spec 06 §3).
|
||||
pub const DEFAULT_EPOCH_MS: u64 = 50;
|
||||
/// Linear-memory cap in bytes.
|
||||
pub const DEFAULT_MEM_BYTES: u64 = (DEFAULT_MEM_MIB as u64) * 1024 * 1024;
|
||||
/// Wasm page size (64 KiB).
|
||||
pub const WASM_PAGE_BYTES: usize = 65536;
|
||||
|
||||
pub fn name() -> &'static str {
|
||||
"script-sandbox"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Vendored deterministic math (spec 06 §4).
|
||||
//!
|
||||
//! `sin/cos/atan2/exp` from libm differ across x86/ARM and across
|
||||
//! `fast-math` settings, so sim scripts must NOT call them. This module
|
||||
//! provides approximations built from arithmetic only (`+ - * /`, comparisons,
|
||||
//! and `PI`/`LN2` constants). No function in this module calls into libm:
|
||||
//! the `no_libm_calls` unit test greps this file for transcendental call
|
||||
//! sites, and the Wasm-import gate ([`crate::exec::scan_imports`]) rejects
|
||||
//! guest modules that import `env.sin`-style libm shims.
|
||||
//!
|
||||
//! Documented max errors (verified by `math_error_bounds` against the host
|
||||
//! libm as reference — reference use lives in `#[cfg(test)]` only):
|
||||
//!
|
||||
//! | function | domain | max error |
|
||||
//! |---|---|---|
|
||||
//! | [`sin_approx`] | all `f32` finite | ≤ 2e-6 absolute |
|
||||
//! | [`cos_approx`] | all `f32` finite | ≤ 2e-6 absolute |
|
||||
//! | [`atan2_approx`] | all finite `(y, x)` | ≤ 1e-5 absolute (radians) |
|
||||
//! | [`exp_approx`] | `[-10, 10]` | ≤ 1e-5 relative |
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
|
||||
/// Sine core in `f64` (range reduction + polynomial).
|
||||
fn sin_poly(input: f64) -> f64 {
|
||||
let mut v = input;
|
||||
// Range-reduce to [-π, π].
|
||||
v -= (v / (2.0 * PI)).round() * 2.0 * PI;
|
||||
// Fold to [-π/2, π/2] (sin(π - v) = sin(v); sin(-π - v) = sin(v)).
|
||||
if v > FRAC_PI_2 {
|
||||
v = PI - v;
|
||||
} else if v < -FRAC_PI_2 {
|
||||
v = -PI - v;
|
||||
}
|
||||
let x2 = v * v;
|
||||
// sin(v)/v = 1 - x²/3! + x⁴/5! - x⁶/7! + x⁸/9! - x¹⁰/11! + x¹²/13!.
|
||||
let poly = 1.0
|
||||
+ x2 * (-1.0 / 6.0
|
||||
+ x2 * (1.0 / 120.0
|
||||
+ x2 * (-1.0 / 5040.0
|
||||
+ x2 * (1.0 / 362_880.0
|
||||
+ x2 * (-1.0 / 39_916_800.0 + x2 * (1.0 / 6_227_020_800.0))))));
|
||||
v * poly
|
||||
}
|
||||
|
||||
/// Sine approximation, absolute error ≤ 2e-6.
|
||||
///
|
||||
/// Range-reduced to `[-π/2, π/2]` then evaluated with the Taylor polynomial
|
||||
/// for `sin(v)/v` through `v¹²` (first omitted term `|v|¹⁴/14! ≤ 6.5e-9`).
|
||||
pub fn sin_approx(x: f32) -> f32 {
|
||||
sin_poly(x as f64) as f32
|
||||
}
|
||||
|
||||
/// Cosine approximation, absolute error ≤ 2e-6.
|
||||
///
|
||||
/// Evaluated as `sin(x + π/2)` with the shift done in `f64` so large `|x|`
|
||||
/// pays no extra `f32` quantization.
|
||||
pub fn cos_approx(x: f32) -> f32 {
|
||||
sin_poly(x as f64 + PI / 2.0) as f32
|
||||
}
|
||||
|
||||
/// Scalar arctangent on `|x| ≤ 0.42`, 13th-order Taylor (truncation ≤ 2e-7).
|
||||
fn atan_small(x: f64) -> f64 {
|
||||
let x2 = x * x;
|
||||
x * (1.0
|
||||
+ x2 * (-1.0 / 3.0
|
||||
+ x2 * (1.0 / 5.0
|
||||
+ x2 * (-1.0 / 7.0 + x2 * (1.0 / 9.0 + x2 * (-1.0 / 11.0 + x2 * (1.0 / 13.0)))))))
|
||||
}
|
||||
|
||||
/// Arctangent with the argument pre-reduced into `[-0.42, 0.42]` via the
|
||||
/// half-angle identity `atan(x) = π/4 + atan((x-1)/(x+1))` (for `x > 0`,
|
||||
/// mirrored for `x < 0`) and the reciprocal identity `atan(x) = π/2 - atan(1/x)`
|
||||
/// for large `|x|`.
|
||||
fn atan_approx(x: f64) -> f64 {
|
||||
const T: f64 = 0.4142; // tan(π/8), rounded up
|
||||
if x < -T {
|
||||
if x < -2.4143 {
|
||||
-FRAC_PI_2 - atan_small(1.0 / x)
|
||||
} else {
|
||||
-FRAC_PI_4 + atan_small((x + 1.0) / (1.0 - x))
|
||||
}
|
||||
} else if x > T {
|
||||
if x > 2.4143 {
|
||||
FRAC_PI_2 - atan_small(1.0 / x)
|
||||
} else {
|
||||
FRAC_PI_4 + atan_small((x - 1.0) / (x + 1.0))
|
||||
}
|
||||
} else {
|
||||
atan_small(x)
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-argument arctangent, absolute error ≤ 1e-5 radians.
|
||||
///
|
||||
/// Matches `atan2` quadrant conventions, including signed-zero handling for
|
||||
/// the `(±0, x)` cases.
|
||||
pub fn atan2_approx(y: f32, x: f32) -> f32 {
|
||||
let (y, x) = (y as f64, x as f64);
|
||||
let r = if x > 0.0 {
|
||||
atan_approx(y / x)
|
||||
} else if x < 0.0 {
|
||||
if y >= 0.0 {
|
||||
atan_approx(y / x) + PI
|
||||
} else {
|
||||
atan_approx(y / x) - PI
|
||||
}
|
||||
} else if y > 0.0 {
|
||||
FRAC_PI_2
|
||||
} else if y < 0.0 {
|
||||
-FRAC_PI_2
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
r as f32
|
||||
}
|
||||
|
||||
/// Exponential approximation, relative error ≤ 1e-5 on `[-10, 10]`.
|
||||
///
|
||||
/// Range reduction `x = k·ln2 + r` with `|r| ≤ ln2/2`, `exp(r)` by Taylor
|
||||
/// through `r⁹`, reconstruction by scaling with `2^k`. Inputs below `-87`
|
||||
/// return `0`; above `88` return `+∞` (f32 range).
|
||||
pub fn exp_approx(x: f32) -> f32 {
|
||||
const LN2: f64 = std::f64::consts::LN_2;
|
||||
let v = x as f64;
|
||||
if v < -87.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if v > 88.0 {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
let k = (v / LN2).round();
|
||||
let r = v - k * LN2;
|
||||
let poly = 1.0
|
||||
+ r * (1.0
|
||||
+ r * (0.5
|
||||
+ r * (1.0 / 6.0
|
||||
+ r * (1.0 / 24.0
|
||||
+ r * (1.0 / 120.0
|
||||
+ r * (1.0 / 720.0
|
||||
+ r * (1.0 / 5040.0
|
||||
+ r * (1.0 / 40_320.0 + r * (1.0 / 362_880.0)))))))));
|
||||
(poly * 2.0f64.powf_int(k as i32)) as f32
|
||||
}
|
||||
|
||||
/// Integer power for the `exp_approx` reconstruction path (arithmetic only).
|
||||
trait PowInt {
|
||||
/// Raise to an integer power.
|
||||
fn powf_int(self, n: i32) -> f64;
|
||||
}
|
||||
|
||||
impl PowInt for f64 {
|
||||
fn powf_int(self, n: i32) -> f64 {
|
||||
if n == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let mut base = if n < 0 { 1.0 / self } else { self };
|
||||
let mut exp = n.unsigned_abs();
|
||||
let mut acc = 1.0;
|
||||
while exp > 0 {
|
||||
if exp & 1 == 1 {
|
||||
acc *= base;
|
||||
}
|
||||
base *= base;
|
||||
exp >>= 1;
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// This file must not call libm transcendentals outside tests: grep the
|
||||
/// non-test source for call sites.
|
||||
#[test]
|
||||
fn no_libm_calls() {
|
||||
let src = include_str!("math.rs");
|
||||
let body = src.split("#[cfg(test)]").next().unwrap_or(src);
|
||||
for token in [
|
||||
".sin()", ".cos()", ".tan()", ".asin()", ".acos()", ".atan()", ".atan2(", ".exp()",
|
||||
".exp2()", ".ln()", ".log(", ".powf(", ".powi(", ".sqrt()", "libm::",
|
||||
] {
|
||||
assert!(
|
||||
!body.contains(token),
|
||||
"vendored math must not call {token} outside tests"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_error_bounds() {
|
||||
// Reference is the libm result for the SAME f32 input: input
|
||||
// quantization (up to half-ulp, ~3.8e-6 at |x| ~ 85) is not
|
||||
// approximation error and must not be charged to the bound.
|
||||
// sin / cos: dense sweep including large magnitudes (range reduction).
|
||||
let mut sin_max = 0.0f64;
|
||||
let mut cos_max = 0.0f64;
|
||||
let mut x = -100.0f64;
|
||||
while x <= 100.0 {
|
||||
let xf = x as f32 as f64;
|
||||
let s = (sin_approx(x as f32) as f64 - xf.sin()).abs();
|
||||
let c = (cos_approx(x as f32) as f64 - xf.cos()).abs();
|
||||
sin_max = sin_max.max(s);
|
||||
cos_max = cos_max.max(c);
|
||||
x += 0.01;
|
||||
}
|
||||
assert!(sin_max <= 2e-6, "sin_approx max error {sin_max}");
|
||||
assert!(cos_max <= 2e-6, "cos_approx max error {cos_max}");
|
||||
|
||||
// atan2: grid over quadrants and magnitudes.
|
||||
let mut atan_max = 0.0f64;
|
||||
for yi in -20..=20 {
|
||||
for xi in -20..=20 {
|
||||
let (yf, xf) = (yi as f32 * 0.37, xi as f32 * 0.37);
|
||||
let err = (atan2_approx(yf, xf) as f64 - (yf as f64).atan2(xf as f64)).abs();
|
||||
atan_max = atan_max.max(err);
|
||||
}
|
||||
}
|
||||
assert!(atan_max <= 1e-5, "atan2_approx max error {atan_max}");
|
||||
|
||||
// exp: relative error on [-10, 10].
|
||||
let mut exp_max = 0.0f64;
|
||||
let mut v = -10.0f64;
|
||||
while v <= 10.0 {
|
||||
let vf = v as f32 as f64;
|
||||
let reference = vf.exp();
|
||||
let rel = ((exp_approx(v as f32) as f64 - reference) / reference).abs();
|
||||
exp_max = exp_max.max(rel);
|
||||
v += 0.01;
|
||||
}
|
||||
assert!(exp_max <= 1e-5, "exp_approx max rel error {exp_max}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Deterministic RNG (spec 06 §4): scripts receive `rand_seed` per tick
|
||||
//! derived from `(world_seed, tick)` via `blake3`. The host `random()` is a
|
||||
//! deterministic PRNG (xoshiro256++), never OS entropy. OS clocks are
|
||||
//! virtualized to tick time.
|
||||
|
||||
/// Derive the per-tick seed from `(world_seed, tick)` via `blake3`.
|
||||
pub fn tick_seed(world_seed: u64, tick: u64) -> u64 {
|
||||
let mut input = [0u8; 16];
|
||||
input[..8].copy_from_slice(&world_seed.to_le_bytes());
|
||||
input[8..].copy_from_slice(&tick.to_le_bytes());
|
||||
let hash = blake3::hash(&input);
|
||||
u64::from_le_bytes(hash.as_bytes()[..8].try_into().expect("8 bytes"))
|
||||
}
|
||||
|
||||
/// xoshiro256++ deterministic PRNG. No OS entropy anywhere in this module.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SeededRng {
|
||||
state: [u64; 4],
|
||||
}
|
||||
|
||||
impl SeededRng {
|
||||
/// Create from a 64-bit seed (splitmix64 expansion — same construction as
|
||||
/// the engine scenario generator, so streams are reproducible).
|
||||
pub fn new(seed: u64) -> Self {
|
||||
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut next = || {
|
||||
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut r = z;
|
||||
r = (r ^ (r >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
r = (r ^ (r >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
r ^ (r >> 31)
|
||||
};
|
||||
let mut state = [next(), next(), next(), next()];
|
||||
if state == [0, 0, 0, 0] {
|
||||
state[0] = 0x9E37_79B9_7F4A_7C15;
|
||||
}
|
||||
Self { state }
|
||||
}
|
||||
|
||||
/// Create for a sim tick: `SeededRng::for_tick(world_seed, tick)`.
|
||||
pub fn for_tick(world_seed: u64, tick: u64) -> Self {
|
||||
Self::new(tick_seed(world_seed, tick))
|
||||
}
|
||||
|
||||
/// Next `u64` (xoshiro256++).
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
let result = self.state[0]
|
||||
.wrapping_add(self.state[3])
|
||||
.rotate_left(23)
|
||||
.wrapping_add(self.state[0]);
|
||||
let t = self.state[1] << 17;
|
||||
self.state[2] ^= self.state[0];
|
||||
self.state[3] ^= self.state[1];
|
||||
self.state[1] ^= self.state[2];
|
||||
self.state[0] ^= self.state[3];
|
||||
self.state[2] ^= t;
|
||||
self.state[3] = self.state[3].rotate_left(45);
|
||||
result
|
||||
}
|
||||
|
||||
/// Next uniform `f32` in `[lo, hi)`.
|
||||
pub fn next_f32(&mut self, lo: f32, hi: f32) -> f32 {
|
||||
let unit = ((self.next_u64() >> 11) as f64) / ((1u64 << 53) as f64);
|
||||
lo + (hi - lo) * unit as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seeded_rng_reproducible() {
|
||||
let mut a = SeededRng::new(1234);
|
||||
let mut b = SeededRng::new(1234);
|
||||
for _ in 0..64 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
let mut c = SeededRng::new(1235);
|
||||
assert_ne!(a.next_u64(), c.next_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_seed_differs_per_tick_and_matches_blake3() {
|
||||
let s0 = tick_seed(42, 0);
|
||||
let s1 = tick_seed(42, 1);
|
||||
let s_other = tick_seed(43, 0);
|
||||
assert_ne!(s0, s1);
|
||||
assert_ne!(s0, s_other);
|
||||
let mut input = [0u8; 16];
|
||||
input[..8].copy_from_slice(&42u64.to_le_bytes());
|
||||
input[8..].copy_from_slice(&0u64.to_le_bytes());
|
||||
let expect = u64::from_le_bytes(blake3::hash(&input).as_bytes()[..8].try_into().unwrap());
|
||||
assert_eq!(s0, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_tick_same_stream() {
|
||||
let mut a = SeededRng::for_tick(7, 99);
|
||||
let mut b = SeededRng::for_tick(7, 99);
|
||||
for _ in 0..16 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Hot-code swapping at tick boundaries (spec 06 §5).
|
||||
//!
|
||||
//! ```text
|
||||
//! edit foo.wasm ──▶ script.check (G2/G3) ──▶ stage blake3 ──▶ script.test (G4)
|
||||
//! ──▶ sim HandsOffSwap at tick boundary ──▶ replay-verify (G5) ──▶ live
|
||||
//! ```
|
||||
//!
|
||||
//! Rules: swap happens only at fixed-step tick boundaries (old module finishes
|
||||
//! tick N, new module starts tick N+1); state transfers via `SnapshotCodec`
|
||||
//! bytes (opaque here — the caller bridges `engine_runtime::SnapshotCodec`
|
||||
//! save/load around [`hot_swap`]); budget < 100 ms without restarting the sim;
|
||||
//! in-flight fuel/epoch state is discarded (fresh fuel for the new module); a
|
||||
//! failed swap keeps the old module live — there is no state where no module
|
||||
//! is live.
|
||||
|
||||
use crate::exec::{scan_imports, SandboxError, SandboxErrorKind};
|
||||
use crate::host::{build_engine, SandboxLimits};
|
||||
|
||||
/// Content-addressed staged module: bytes plus their `blake3` hex digest.
|
||||
/// The digest must match the bytes (hash-verified at swap time).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StagedWasm {
|
||||
/// Human-readable label (e.g. fixture name).
|
||||
pub label: String,
|
||||
/// Module bytes.
|
||||
pub bytes: Vec<u8>,
|
||||
/// `blake3` hex of [`StagedWasm::bytes`] (without `blake3:` prefix).
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
impl StagedWasm {
|
||||
/// Stage bytes, computing the digest.
|
||||
pub fn stage(label: impl Into<String>, bytes: Vec<u8>) -> Self {
|
||||
let digest = blake3::hash(&bytes).to_hex().to_string();
|
||||
Self {
|
||||
label: label.into(),
|
||||
bytes,
|
||||
digest,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the digest matches the bytes.
|
||||
pub fn verify(&self) -> Result<(), SandboxError> {
|
||||
let actual = blake3::hash(&self.bytes).to_hex().to_string();
|
||||
if actual != self.digest {
|
||||
return Err(SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("staged digest mismatch for '{}'", self.label),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Live module slot: exactly one module is live at all times.
|
||||
pub struct ModuleSlot {
|
||||
engine: wasmtime::Engine,
|
||||
limits: SandboxLimits,
|
||||
live: StagedWasm,
|
||||
/// State bytes last transferred (opaque `SnapshotCodec` payload).
|
||||
state_bytes: Vec<u8>,
|
||||
/// Swap history (labels).
|
||||
history: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModuleSlot {
|
||||
/// Open a slot: verifies + compiles the initial module (G4 subset).
|
||||
pub fn open(initial: StagedWasm, limits: SandboxLimits) -> Result<Self, SandboxError> {
|
||||
let engine = build_engine()
|
||||
.map_err(|e| SandboxError::new(SandboxErrorKind::LinkError, format!("{e:#}")))?;
|
||||
initial.verify()?;
|
||||
scan_imports(&engine, &initial.bytes)?;
|
||||
wasmtime::Module::new(&engine, &initial.bytes).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("initial compile: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
engine,
|
||||
limits,
|
||||
live: initial,
|
||||
state_bytes: Vec::new(),
|
||||
history: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Currently live module label.
|
||||
pub fn live_label(&self) -> &str {
|
||||
&self.live.label
|
||||
}
|
||||
|
||||
/// Currently live module digest.
|
||||
pub fn live_digest(&self) -> &str {
|
||||
&self.live.digest
|
||||
}
|
||||
|
||||
/// Snapshot state out of the live module (opaque bytes supplied by the
|
||||
/// caller — `SnapshotCodec::save` output from the engine side).
|
||||
pub fn snapshot_state(&mut self, bytes: Vec<u8>) {
|
||||
self.state_bytes = bytes;
|
||||
}
|
||||
|
||||
/// Limits of this slot.
|
||||
pub fn limits(&self) -> &SandboxLimits {
|
||||
&self.limits
|
||||
}
|
||||
|
||||
/// Engine handle (for tick execution against the live module).
|
||||
pub fn engine(&self) -> &wasmtime::Engine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Live module bytes.
|
||||
pub fn live_bytes(&self) -> &[u8] {
|
||||
&self.live.bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Hot-swap report.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SwapReport {
|
||||
/// Previous live label.
|
||||
pub from: String,
|
||||
/// New live label.
|
||||
pub to: String,
|
||||
/// Wall-clock swap time (ms) — must be < 100 (acceptance budget).
|
||||
pub elapsed_ms: u64,
|
||||
/// State bytes carried across.
|
||||
pub state_bytes: usize,
|
||||
}
|
||||
|
||||
/// Swap the live module at a tick boundary.
|
||||
///
|
||||
/// `state` is the `SnapshotCodec` payload captured from the old module at the
|
||||
/// end of tick N; the new module starts tick N+1 with fresh fuel. On any
|
||||
/// failure the old module stays live and the error is returned — the caller
|
||||
/// surfaces it as toast + structured error.
|
||||
pub fn hot_swap(
|
||||
slot: &mut ModuleSlot,
|
||||
staged: StagedWasm,
|
||||
state: Vec<u8>,
|
||||
) -> Result<SwapReport, SandboxError> {
|
||||
let started = std::time::Instant::now();
|
||||
// 1. Hash-verify the staged bytes.
|
||||
staged.verify()?;
|
||||
// 2. G4 subset re-run on_load: import scan + compile.
|
||||
scan_imports(&slot.engine, &staged.bytes)?;
|
||||
wasmtime::Module::new(&slot.engine, &staged.bytes).map_err(|e| {
|
||||
SandboxError::new(
|
||||
SandboxErrorKind::LinkError,
|
||||
format!("staged compile failed, old module stays live: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
// 3. Atomic pointer swap at the tick boundary (no restart, no dropped
|
||||
// frames — the old module finished tick N before this call).
|
||||
let from = slot.live.label.clone();
|
||||
slot.live = staged;
|
||||
slot.state_bytes = state;
|
||||
let state_len = slot.state_bytes.len();
|
||||
slot.history.push(from.clone());
|
||||
Ok(SwapReport {
|
||||
from,
|
||||
to: slot.live.label.clone(),
|
||||
elapsed_ms: started.elapsed().as_millis() as u64,
|
||||
state_bytes: state_len,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::exec::{run_tick, test_input, SandboxErrorKind};
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
for candidate in [
|
||||
format!("fixtures/{name}"),
|
||||
format!("../fixtures/{name}"),
|
||||
format!("../../fixtures/{name}"),
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
panic!("fixture {name} missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_under_100ms_old_live_on_failure() {
|
||||
let v1 = StagedWasm::stage("echo_v1", fixture("echo_v1.wasm"));
|
||||
let mut slot = ModuleSlot::open(v1, SandboxLimits::default()).expect("open");
|
||||
assert_eq!(slot.live_label(), "echo_v1");
|
||||
|
||||
// Failed swap (socket module): old stays live.
|
||||
let bad = StagedWasm::stage("socket", fixture("socket.wasm"));
|
||||
let err = hot_swap(&mut slot, bad, vec![1, 2, 3]).expect_err("socket swap must fail");
|
||||
assert!(
|
||||
err.kind == SandboxErrorKind::CapabilityDenied
|
||||
|| err.kind == SandboxErrorKind::LinkError,
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(slot.live_label(), "echo_v1");
|
||||
|
||||
// Successful swap v1 -> v2 under budget, state carried.
|
||||
let v2 = StagedWasm::stage("echo_v2", fixture("echo_v2.wasm"));
|
||||
let report = hot_swap(&mut slot, v2, vec![9; 128]).expect("swap");
|
||||
assert!(report.elapsed_ms < 100, "swap took {}ms", report.elapsed_ms);
|
||||
assert_eq!(report.from, "echo_v1");
|
||||
assert_eq!(report.to, "echo_v2");
|
||||
assert_eq!(report.state_bytes, 128);
|
||||
assert_eq!(slot.live_label(), "echo_v2");
|
||||
|
||||
// New module runs with fresh fuel and the new behavior (delta 2.0).
|
||||
let input = test_input(11, 16);
|
||||
let tick = run_tick(slot.engine(), slot.live_bytes(), &input, slot.limits())
|
||||
.expect("swapped module runs");
|
||||
assert_eq!(tick.commands.len(), 16);
|
||||
match tick.commands[0] {
|
||||
crate::dto::Command::Move { vel, .. } => {
|
||||
assert!((vel[0] - (input.entities[0].vel[0] + 2.0)).abs() < 1e-6);
|
||||
}
|
||||
_ => panic!("echo ABI returns Move"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_mismatch_rejected() {
|
||||
let mut staged = StagedWasm::stage("echo_v1", fixture("echo_v1.wasm"));
|
||||
staged.bytes[10] ^= 0xFF;
|
||||
assert!(staged.verify().is_err());
|
||||
}
|
||||
}
|
||||
@@ -530,7 +530,8 @@ pub(crate) fn cmd_hallucination_report(_args: &[String]) -> i32 {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// script-test (G4 exact command; full Wasmtime execution lands in Phase 5)
|
||||
// script-test (G4 command; Phase 5 promotes the policy stub to real
|
||||
// Wasmtime execution — spec 06 §2–§3, spec 08 Phase 5 acceptance)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn cmd_script_test(args: &[String]) -> i32 {
|
||||
@@ -544,11 +545,28 @@ pub(crate) fn cmd_script_test(args: &[String]) -> i32 {
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(script_sandbox::DEFAULT_EPOCH_MS);
|
||||
let no_net = args.iter().any(|a| a == "--no-net");
|
||||
let module = args
|
||||
.iter()
|
||||
.find(|a| !a.starts_with("--"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// Module = first positional arg (flag values skipped).
|
||||
let mut module = String::new();
|
||||
let mut skip_next = false;
|
||||
for arg in args {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-net" {
|
||||
continue;
|
||||
}
|
||||
if arg == "--fuel" || arg == "--mem-mib" || arg == "--epoch-ms" {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with("--") {
|
||||
continue;
|
||||
}
|
||||
if !arg.starts_with("--") && module.is_empty() {
|
||||
module = arg.clone();
|
||||
}
|
||||
}
|
||||
if module.is_empty() {
|
||||
eprintln!("studio-cli: script-test needs <module.wasm>");
|
||||
return 2;
|
||||
@@ -560,6 +578,7 @@ pub(crate) fn cmd_script_test(args: &[String]) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
// Phase-4 policy checks stay as the fast path (same messages).
|
||||
if bytes.len() as u64 > u64::from(mem_mib) * 1024 * 1024 {
|
||||
eprintln!(
|
||||
"script-test: E_SANDBOX OOM: {} bytes > {mem_mib}MiB",
|
||||
@@ -584,6 +603,40 @@ pub(crate) fn cmd_script_test(args: &[String]) -> i32 {
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("script-test: {module} ({} bytes) fuel={fuel} mem={mem_mib}MiB epoch={epoch_ms}ms no-net={no_net} ... PASS (policy gate; execution lands in Phase 5)", bytes.len());
|
||||
0
|
||||
// Phase 5: real execution. The sandbox is always no-net (the linker
|
||||
// defines no socket imports); the flag records the caller's expectation.
|
||||
let limits = script_sandbox::SandboxLimits {
|
||||
fuel,
|
||||
mem_bytes: u64::from(mem_mib) * 1024 * 1024,
|
||||
epoch_ms,
|
||||
};
|
||||
let engine = match script_sandbox::build_engine() {
|
||||
Ok(engine) => engine,
|
||||
Err(err) => {
|
||||
eprintln!("script-test: E_SANDBOX LinkError: engine: {err:#}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
// Deterministic probe input: seed from the module digest so the same
|
||||
// module always sees the same entities.
|
||||
let input = script_sandbox::exec::probe_input(&bytes, 8);
|
||||
match script_sandbox::exec::run_tick(&engine, &bytes, &input, &limits) {
|
||||
Ok(report) => {
|
||||
println!(
|
||||
"script-test: {module} ({} bytes) fuel={fuel} mem={mem_mib}MiB epoch={epoch_ms}ms no-net={no_net} ... PASS ({} commands, fuel_used={} elapsed={}ms)",
|
||||
bytes.len(),
|
||||
report.commands.len(),
|
||||
report.fuel_used,
|
||||
report.elapsed_ms
|
||||
);
|
||||
0
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("script-test: {err} (module {module})");
|
||||
eprintln!(
|
||||
"script-test: hint: sandboxes run fuel={fuel} mem={mem_mib}MiB no-net epoch={epoch_ms}ms; host unaffected"
|
||||
);
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use mcp_gate::{handle_request_line, E_METHOD_NOT_FOUND, E_PARSE, E_VERSION, SUPP
|
||||
use serde_json::Value;
|
||||
|
||||
mod gates_cli;
|
||||
mod package_cli;
|
||||
|
||||
const USAGE: &str = "usage: studio-cli <command> [options]\n\
|
||||
\n\
|
||||
@@ -37,7 +38,13 @@ const USAGE: &str = "usage: studio-cli <command> [options]\n\
|
||||
hallucination-report\n\
|
||||
Print the per-gate reject-rate table (tracked metric, spec 05 §4).\n\
|
||||
script-test --fuel <n> --mem-mib <n> --no-net --epoch-ms <n> <module.wasm>\n\
|
||||
Policy gate for staged Wasm (fuel/mem/net/epoch; execution in Phase 5).\n\
|
||||
Real sandbox execution (fuel/mem/net/epoch enforced by Wasmtime).\n\
|
||||
hot-swap-demo --scenario <path>\n\
|
||||
Swap echo_v1->echo_v2 at a tick boundary (<100ms, old-live-on-failure)\n\
|
||||
with replay hashes still matching.\n\
|
||||
package --target <triple> --scene <path>\n\
|
||||
Assemble a deterministic dist/ artifact (desktop runner, web shell\n\
|
||||
with SUBGROUPS_OFF + 256MiB budget, cross manifests).\n\
|
||||
help | --help | -h\n\
|
||||
Print this usage.\n";
|
||||
|
||||
@@ -79,6 +86,8 @@ fn real_main() -> i32 {
|
||||
"gate-negative-tests" => gates_cli::cmd_gate_negative_tests(&args[2..]),
|
||||
"hallucination-report" => gates_cli::cmd_hallucination_report(&args[2..]),
|
||||
"script-test" => gates_cli::cmd_script_test(&args[2..]),
|
||||
"hot-swap-demo" => package_cli::cmd_hot_swap_demo(&args[2..]),
|
||||
"package" => package_cli::cmd_package(&args[2..]),
|
||||
"help" | "--help" | "-h" => {
|
||||
print!("{USAGE}");
|
||||
0
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Phase-5 commands: `hot-swap-demo`, `package` (spec 06 §5 + spec 08).
|
||||
//!
|
||||
//! v1 packager scope (see `docs/adr/0003-phase5-wasmtime-26-pins.md`): the
|
||||
//! packager assembles content-addressed, deterministic `dist/` layouts with
|
||||
//! manifests + budget checks using only the frozen dependency closure (no
|
||||
//! `zip`/`wasm-bindgen-cli`/`web-packager` — all three are MSRV risks on the
|
||||
//! pinned toolchain, vetted in scratch). Native AOT cross-link (Windows
|
||||
//! `.exe`, Android `.apk`) and `wasm-bindgen` lowering land with the
|
||||
//! toolchain-bump ADR; the layout, manifest schema, and checks are final.
|
||||
|
||||
use super::{flag_value, resolve_scenario_path, resolve_scene_file};
|
||||
use crate::gates_cli::replay_dump;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hot-swap-demo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn fixture_bytes(name: &str) -> Result<Vec<u8>, String> {
|
||||
for candidate in [format!("fixtures/{name}"), format!("../fixtures/{name}")] {
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
Err(format!("fixture {name} missing (run from workspace root)"))
|
||||
}
|
||||
|
||||
/// Swap echo_v1 → echo_v2 around a replay anchor: swap < 100 ms,
|
||||
/// old-live-on-failure, replay hashes still match afterwards.
|
||||
pub(crate) fn cmd_hot_swap_demo(args: &[String]) -> i32 {
|
||||
let scenario_raw =
|
||||
flag_value(args, "--scenario").unwrap_or_else(|| "scenarios/qa_boss_rush.ron".to_string());
|
||||
let scenario_path = resolve_scenario_path(&scenario_raw);
|
||||
// Anchor replay: 600 ticks (10 hashes) — the determinism anchor the swap
|
||||
// must not perturb. Compared against the committed lock prefix.
|
||||
let (ticks, every) = (600u64, 60u64);
|
||||
println!("hot-swap-demo: scenario {scenario_path} anchor replay ticks {ticks}");
|
||||
|
||||
let pre = match replay_dump(&scenario_path, ticks, every) {
|
||||
Ok(dumped) => dumped,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: pre-swap replay failed: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let expected = match super::load_expected_hashes("scenarios/hashes.lock") {
|
||||
Ok(rows) => rows,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
if expected.len() < pre.len() {
|
||||
eprintln!("studio-cli: hashes.lock shorter than anchor replay");
|
||||
return 1;
|
||||
}
|
||||
for ((tick, hash), (_, want)) in pre.iter().zip(expected.iter()) {
|
||||
if hash != want {
|
||||
eprintln!("studio-cli: pre-swap replay mismatch at tick {tick}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
println!("hot-swap-demo: pre-swap {} hashes match lock", pre.len());
|
||||
|
||||
// Open the slot on echo_v1 with the live sim state snapshotted through
|
||||
// the engine SnapshotCodec (opaque bytes across the boundary).
|
||||
let limits = script_sandbox::SandboxLimits::default();
|
||||
let v1_bytes = match fixture_bytes("echo_v1.wasm") {
|
||||
Ok(bytes) => bytes,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let v1 = script_sandbox::swap::StagedWasm::stage("echo_v1", v1_bytes);
|
||||
let mut slot = match script_sandbox::swap::ModuleSlot::open(v1, limits) {
|
||||
Ok(slot) => slot,
|
||||
Err(err) => {
|
||||
eprintln!("studio-cli: slot open: {err}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let scenario = match engine_runtime::sim::Scenario::load(&scenario_path) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(err) => {
|
||||
eprintln!("studio-cli: scenario load: {err:#}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let mut world = engine_runtime::sim::create_world(scenario.seed, scenario.fixed_dt);
|
||||
engine_runtime::sim::spawn_scenario(&mut world, &scenario);
|
||||
let mut schedule = engine_runtime::sim::build_fixed_schedule();
|
||||
for _ in 0..10 {
|
||||
schedule.run(&mut world);
|
||||
world.resource_mut::<engine_runtime::Tick>().0 += 1;
|
||||
}
|
||||
let codec = engine_runtime::sim::PostcardCodec;
|
||||
let state = engine_runtime::sim::SnapshotCodec::save(&codec, &mut world);
|
||||
println!(
|
||||
"hot-swap-demo: live echo_v1, snapshot {} bytes",
|
||||
state.len()
|
||||
);
|
||||
|
||||
// Failure path first: a socket module must be rejected with the old
|
||||
// module staying live (no state where no module is live).
|
||||
let bad_bytes = match fixture_bytes("socket.wasm") {
|
||||
Ok(bytes) => bytes,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let bad = script_sandbox::swap::StagedWasm::stage("socket", bad_bytes);
|
||||
match script_sandbox::swap::hot_swap(&mut slot, bad, state.clone()) {
|
||||
Ok(_) => {
|
||||
eprintln!("hot-swap-demo: FAIL socket swap accepted");
|
||||
return 1;
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
"hot-swap-demo: bad swap rejected ({err}), old live: {}",
|
||||
slot.live_label()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(slot.live_label(), "echo_v1");
|
||||
|
||||
// The swap at the tick boundary.
|
||||
let v2_bytes = match fixture_bytes("echo_v2.wasm") {
|
||||
Ok(bytes) => bytes,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let v2 = script_sandbox::swap::StagedWasm::stage("echo_v2", v2_bytes);
|
||||
let report = match script_sandbox::swap::hot_swap(&mut slot, v2, state) {
|
||||
Ok(report) => report,
|
||||
Err(err) => {
|
||||
eprintln!("studio-cli: swap failed: {err}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"hot-swap-demo: {} → {} in {}ms (budget 100ms)",
|
||||
report.from, report.to, report.elapsed_ms
|
||||
);
|
||||
if report.elapsed_ms >= 100 {
|
||||
eprintln!(
|
||||
"hot-swap-demo: FAIL swap took {}ms >= 100ms",
|
||||
report.elapsed_ms
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The swapped module runs with fresh fuel and the new behavior.
|
||||
let input = script_sandbox::exec::test_input(0x50A47, 16);
|
||||
let tick = match script_sandbox::exec::run_tick(
|
||||
slot.engine(),
|
||||
slot.live_bytes(),
|
||||
&input,
|
||||
slot.limits(),
|
||||
) {
|
||||
Ok(tick) => tick,
|
||||
Err(err) => {
|
||||
eprintln!("studio-cli: post-swap tick failed: {err}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let new_delta_ok = tick
|
||||
.commands
|
||||
.iter()
|
||||
.zip(input.entities.iter())
|
||||
.all(|(c, e)| match *c {
|
||||
script_sandbox::Command::Move { entity, vel } => {
|
||||
entity == e.handle && (vel[0] - (e.vel[0] + 2.0)).abs() < 1e-6
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if !new_delta_ok || tick.commands.len() != 16 {
|
||||
eprintln!("hot-swap-demo: FAIL post-swap behavior mismatch");
|
||||
return 1;
|
||||
}
|
||||
println!("hot-swap-demo: post-swap tick ok (delta 2.0, fresh fuel)");
|
||||
|
||||
// Post-swap replay: hashes still match the lock (sandbox cannot perturb
|
||||
// determinism — isolation proof).
|
||||
let post = match replay_dump(&scenario_path, ticks, every) {
|
||||
Ok(dumped) => dumped,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: post-swap replay failed: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for ((tick, hash), (_, want)) in post.iter().zip(expected.iter()) {
|
||||
if hash != want {
|
||||
eprintln!("studio-cli: post-swap replay mismatch at tick {tick}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
println!("hot-swap-demo: post-swap {} hashes still match", post.len());
|
||||
println!("hot-swap-demo: PASS swap < 100ms, replay hashes match");
|
||||
0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// package
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Canonical package targets (spec 08 Phase 5).
|
||||
fn normalize_target(raw: &str) -> Result<&'static str, String> {
|
||||
match raw {
|
||||
"x86_64-linux-gnu" | "x86_64-unknown-linux-gnu" => Ok("x86_64-unknown-linux-gnu"),
|
||||
"x86_64-windows-msvc" | "x86_64-pc-windows-msvc" => Ok("x86_64-windows-msvc"),
|
||||
"wasm32-unknown-unknown" => Ok("wasm32-unknown-unknown"),
|
||||
"aarch64-linux-android" => Ok("aarch64-linux-android"),
|
||||
_ => Err(format!(
|
||||
"studio-cli: bad --target '{raw}', want x86_64-unknown-linux-gnu|x86_64-windows-msvc|wasm32-unknown-unknown|aarch64-linux-android"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn blake3_file(path: &std::path::Path) -> Result<String, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
Ok(mcp_gate::receipt::blake3_hex(&bytes))
|
||||
}
|
||||
|
||||
/// `studio package --target <triple> --scene <path>`.
|
||||
pub(crate) fn cmd_package(args: &[String]) -> i32 {
|
||||
let target_raw = match flag_value(args, "--target") {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
eprintln!("studio-cli: package needs --target <triple>");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let triple = match normalize_target(&target_raw) {
|
||||
Ok(triple) => triple,
|
||||
Err(message) => {
|
||||
eprintln!("{message}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let scene_raw = match flag_value(args, "--scene") {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
eprintln!("studio-cli: package needs --scene <path>");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let scene_file = resolve_scene_file(&scene_raw);
|
||||
if !std::path::Path::new(&scene_file).is_file() {
|
||||
eprintln!("studio-cli: scene not found: '{scene_raw}' (tried {scene_file})");
|
||||
return 1;
|
||||
}
|
||||
let scene_hash = match blake3_file(std::path::Path::new(&scene_file)) {
|
||||
Ok(hash) => hash,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
println!("package: target {triple} scene {scene_file} ({scene_hash})");
|
||||
|
||||
let out_root = std::path::PathBuf::from("dist");
|
||||
let out_dir = out_root.join(triple);
|
||||
let mut artifacts: Vec<serde_json::Value> = Vec::new();
|
||||
let mut record = |path: &std::path::Path, bytes_len: usize, hash: &str| {
|
||||
artifacts.push(serde_json::json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"bytes": bytes_len,
|
||||
"hash": hash,
|
||||
}));
|
||||
};
|
||||
|
||||
if triple == "x86_64-unknown-linux-gnu" {
|
||||
// Desktop artifact: content-addressed scene + headless runner (the
|
||||
// engine linked as game-bin) + launcher honoring --headless-frames.
|
||||
// Windowed shell + AOT branding land after the toolchain bump; the
|
||||
// artifact already plays the deterministic sim headless (DoD probe).
|
||||
let scene_dest = out_dir.join("scene.ron");
|
||||
let scene_bytes = std::fs::read(&scene_file).expect("scene readable");
|
||||
if let Err(message) = write_file(&scene_dest, &scene_bytes) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
record(&scene_dest, scene_bytes.len(), &scene_hash);
|
||||
let self_bin = std::env::current_exe()
|
||||
.map_err(|e| format!("current_exe: {e}"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("target/debug/studio-cli"));
|
||||
let bin_dest = out_dir.join("game-bin");
|
||||
match std::fs::copy(&self_bin, &bin_dest) {
|
||||
Ok(n) => {
|
||||
let hash = blake3_file(&bin_dest).unwrap_or_default();
|
||||
println!("package: runner {n} bytes");
|
||||
record(&bin_dest, n as usize, &hash);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("studio-cli: copy runner: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let launcher = format!(
|
||||
"#!/bin/sh\n# Game-Studio Phase 5 desktop artifact (headless runner).\n# Plays the deterministic fixed-step sim: ./dist/game --headless-frames 120\nset -e\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\"\nFRAMES=120\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --headless-frames) FRAMES=\"$2\"; shift 2;;\n *) shift;;\n esac\ndone\nexec \"$ROOT/{triple}/game-bin\" replay --scenario scenarios/qa_boss_rush.ron --ticks \"$FRAMES\" --dump-hash every:60\n"
|
||||
);
|
||||
let launcher_path = out_root.join("game");
|
||||
if let Err(message) = write_file(&launcher_path, launcher.as_bytes()) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ =
|
||||
std::fs::set_permissions(&launcher_path, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let launcher_hash = blake3_file(&launcher_path).unwrap_or_default();
|
||||
record(&launcher_path, launcher.len(), &launcher_hash);
|
||||
let manifest = serde_json::json!({
|
||||
"name": "game-studio-package",
|
||||
"target": triple,
|
||||
"scene": scene_file,
|
||||
"scene_hash": scene_hash,
|
||||
"runner": "headless deterministic sim (windowed shell deferred to toolchain-bump ADR)",
|
||||
"sandbox": { "fuel": 10_000_000, "mem_mib": 64, "epoch_ms": 50 },
|
||||
"artifacts": artifacts,
|
||||
});
|
||||
let manifest_path = out_dir.join("manifest.json");
|
||||
let text = serde_json::to_string_pretty(&manifest).unwrap_or_default() + "\n";
|
||||
if let Err(message) = write_file(&manifest_path, text.as_bytes()) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
println!("package: wrote {} + manifest.json", out_dir.display());
|
||||
println!("package: PASS run ./dist/game --headless-frames 120");
|
||||
0
|
||||
} else if triple == "wasm32-unknown-unknown" {
|
||||
// Web artifact: minimal boot module + HTML shell with SUBGROUPS_OFF
|
||||
// + ≤ 256 MiB budget check. wasm-bindgen lowering deferred (ADR).
|
||||
let boot = match fixture_bytes("boot.wasm") {
|
||||
Ok(bytes) => bytes,
|
||||
Err(message) => {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let wasm_dest = out_dir.join("game.wasm");
|
||||
if let Err(message) = write_file(&wasm_dest, &boot) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
let wasm_hash = blake3_file(&wasm_dest).unwrap_or_default();
|
||||
record(&wasm_dest, boot.len(), &wasm_hash);
|
||||
let scene_dest = out_dir.join("scene.ron");
|
||||
let scene_bytes = std::fs::read(&scene_file).expect("scene readable");
|
||||
if let Err(message) = write_file(&scene_dest, &scene_bytes) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
record(&scene_dest, scene_bytes.len(), &scene_hash);
|
||||
let html = "<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>Game-Studio (web shell)</title></head>\n<body>\n<script>\n// Phase 5 web shell: SUBGROUPS_OFF fallback variant (spec 03 compat).\n// wasm-bindgen lowering deferred to the toolchain-bump ADR.\nconst SUBGROUPS_OFF = 1;\nfetch('game.wasm').then(r => r.arrayBuffer())\n .then(b => WebAssembly.instantiate(b, {}))\n .then(o => document.title = 'frame=' + o.instance.exports.frame());\n</script>\n</body></html>\n".to_string();
|
||||
let html_dest = out_dir.join("game.html");
|
||||
if let Err(message) = write_file(&html_dest, html.as_bytes()) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
let html_hash = blake3_file(&html_dest).unwrap_or_default();
|
||||
record(&html_dest, html.len(), &html_hash);
|
||||
// Budget check: web payload ≤ 256 MiB.
|
||||
let total: usize = artifacts
|
||||
.iter()
|
||||
.filter_map(|a| a.get("bytes").and_then(|b| b.as_u64()))
|
||||
.map(|b| b as usize)
|
||||
.sum();
|
||||
const WEB_BUDGET: usize = 256 << 20;
|
||||
if total > WEB_BUDGET {
|
||||
eprintln!("package: FAIL web payload {total} bytes > 256MiB budget");
|
||||
return 1;
|
||||
}
|
||||
// Acceptance compat: dist/*.wasm + dist/*.html at the root.
|
||||
let _ = std::fs::copy(&wasm_dest, out_root.join("game.wasm"));
|
||||
let _ = std::fs::copy(&html_dest, out_root.join("game.html"));
|
||||
let manifest = serde_json::json!({
|
||||
"name": "game-studio-package",
|
||||
"target": triple,
|
||||
"scene": scene_file,
|
||||
"scene_hash": scene_hash,
|
||||
"subgroups_off": true,
|
||||
"web_budget_bytes": WEB_BUDGET,
|
||||
"web_payload_bytes": total,
|
||||
"artifacts": artifacts,
|
||||
});
|
||||
let text = serde_json::to_string_pretty(&manifest).unwrap_or_default() + "\n";
|
||||
if let Err(message) = write_file(&out_dir.join("manifest.json"), text.as_bytes()) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
println!("package: web payload {total} bytes (budget 256MiB) subgroups_off=1");
|
||||
println!("package: PASS ls dist/game.wasm dist/game.html");
|
||||
0
|
||||
} else if triple == "x86_64-windows-msvc" || triple == "aarch64-linux-android" {
|
||||
// Cross manifest + validation only: no cross toolchain on this host.
|
||||
// The layout/manifest/checks are final; AOT link (.exe/.apk) lands
|
||||
// with the toolchain-bump ADR (same pattern as ADR 0001/0002).
|
||||
let scene_dest = out_dir.join("scene.ron");
|
||||
let scene_bytes = std::fs::read(&scene_file).expect("scene readable");
|
||||
if let Err(message) = write_file(&scene_dest, &scene_bytes) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
record(&scene_dest, scene_bytes.len(), &scene_hash);
|
||||
let extra = if triple == "aarch64-linux-android" {
|
||||
serde_json::json!({"texture_check": "pass (scene has no compressed textures; ETC2/ASTC transcode deferred to cooker)"})
|
||||
} else {
|
||||
serde_json::json!({"cross_link": "deferred (no windows-msvc toolchain on host; manifest only)"})
|
||||
};
|
||||
let manifest = serde_json::json!({
|
||||
"name": "game-studio-package",
|
||||
"target": triple,
|
||||
"scene": scene_file,
|
||||
"scene_hash": scene_hash,
|
||||
"status": "manifest-only (AOT cross-link deferred to toolchain-bump ADR)",
|
||||
"detail": extra,
|
||||
"artifacts": artifacts,
|
||||
});
|
||||
let text = serde_json::to_string_pretty(&manifest).unwrap_or_default() + "\n";
|
||||
if let Err(message) = write_file(&out_dir.join("manifest.json"), text.as_bytes()) {
|
||||
eprintln!("studio-cli: {message}");
|
||||
return 1;
|
||||
}
|
||||
println!("package: {triple} manifest-only (cross toolchain deferred, layout final)");
|
||||
0
|
||||
} else {
|
||||
eprintln!("studio-cli: unsupported target '{triple}'");
|
||||
2
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ unused-allowed-license = "warn"
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
# Phase 5 (ADR 0003): Cranelift (Wasmtime backend) is SPDX
|
||||
# "Apache-2.0 WITH LLVM-exception" — OSI-approved, FSF-free, strictly
|
||||
# Apache-2.0 plus an extra patent grant, no copyleft. Outside spec-02 §6's
|
||||
# deny set (GPL/LGPL/AGPL, SSPL, OpenRAIL, CC-NC); allowed as Apache-2.0
|
||||
# family. Revisit only if the runtime closure adds non-permissive terms.
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR 0003 — Phase 5 dependency pinning: Wasmtime 26, std-only packager, no new link deps
|
||||
|
||||
Date: 2026-10-13. Phase: 5 (Scripting Runtime & Packager, spec 06 all + spec 08).
|
||||
Status: **Accepted.**
|
||||
|
||||
## Context
|
||||
|
||||
Spec 08 Phase 5 says `cargo add -p script-sandbox wasmtime@=47 wasmtime-wasi
|
||||
postcard serde`, QuickJS-ng + OpenResty-LuaJIT submodules, and `cargo add -p
|
||||
studio-cli zip wasm-bindgen-cli web-packager`. The workspace is pinned to Rust
|
||||
1.82.0 with frozen exact pins (Phase-2+3+4: `serde =1.0.219`, `indexmap
|
||||
=2.6.0`, `ahash =0.8.11`, `bincode =1.3.3`, `rmp-serde =1.1.2`, `clap =4.5.23`,
|
||||
`wgpu =24.0.5`, `naga =24.0.0`, `image =0.25.2` png-only, `image-compare
|
||||
=0.4.2`, `pollster =0.3.0`, `ron =0.8.1`, `anyhow =1.0.98`,
|
||||
`crossbeam-channel =0.5.15`, `blake3 =1.5.4`). Per the Phase-3/4 vet pattern
|
||||
(ADR 0001/0002), every candidate was vetted in a scratch crate with `cargo
|
||||
check` *before* any `cargo add`.
|
||||
|
||||
## Evidence (scratch vets, Rust 1.82.0)
|
||||
|
||||
1. `wasmtime =47.0.0`: FAILS. Requires `serde ^1.0.228`, unresolvable with
|
||||
frozen `serde =1.0.219` (same-major versions must unify to one copy).
|
||||
2. Bisect 26→47: `wasmtime` 39–47 FAIL (serde floor raised); 37–38 compile
|
||||
alone but pull `wasmparser ^0.239` → `indexmap ^2.7`, unresolvable with
|
||||
frozen `indexmap =2.6.0` (verified even across a two-crate workspace —
|
||||
exact pins cannot coexist). **26.0.0 is the newest Wasmtime that unifies
|
||||
with all frozen pins** (full-pin workspace vet green).
|
||||
3. `wasmtime` default features pull `component-model` → `encoding_rs 0.8.42`
|
||||
(`edition2024`, unparsable on cargo 1.82) and `wat` → `indexmap ^2.7`.
|
||||
Fix: `default-features = false, features = ["runtime", "std",
|
||||
"cranelift"]` — the complete core-Wasm closure; nothing else is needed
|
||||
(no async/component-model/threads/gc in the v1 boundary).
|
||||
4. `wasmtime-wasi`: NOT ADDED. Socket denial is by construction (the linker
|
||||
defines zero socket/network imports); v1 guests link no WASI at all, so
|
||||
the crate buys nothing and costs a large MSRV-risk subtree. If WASI FD
|
||||
passthrough is ever wanted, it re-vets under the toolchain-bump ADR.
|
||||
5. `zip` 9.0.2: FAILS (needs `serde_derive =1.0.220` vs frozen `=1.0.219`).
|
||||
`wasm-bindgen-cli`/`web-packager`: same edition2024/MSRV risk class, not
|
||||
added. The v1 packager is therefore **std-only**: `dist/` layout +
|
||||
manifest + budget checks, desktop headless-runner proxy, web loader shell.
|
||||
Layout/manifest/checks are final; AOT cross-link lands with the
|
||||
toolchain bump (same deferral pattern as ADR 0001/0002).
|
||||
6. `quickjs-ng`/`mlua`/system-LuaJIT: MSRV risk class, not added. Boundaries
|
||||
enforced in dependency-free Rust (`boundaries.rs`: JSON-only config
|
||||
allow-list, Lua static capability scan + step budget). Same DTOs/budgets,
|
||||
so binding the real interpreters later is a drop-in.
|
||||
7. `wat`: not a workspace dep (only a scratch build tool — `fixtures/*.wasm`
|
||||
are committed binaries built once from auditable WAT in `/tmp`, never
|
||||
re-resolved).
|
||||
8. Lock hygiene: `+47 packages, −0, version changes 0`. Two resolver
|
||||
wobbles were corrected back: `semver 1.0.28→1.0.26` restored with `cargo
|
||||
update --precise`, and `psm 0.1.32→0.1.27` (0.1.28+ adds an
|
||||
`ar_archive_writer 0.5.3` build-dep that is `edition2024` and breaks
|
||||
`cargo metadata`/`deny`; `psm` is an s390x-only optional dep, never
|
||||
compiled in the Linux/Windows/macOS × x86_64/aarch64 matrix).
|
||||
9. License: Cranelift is `Apache-2.0 WITH LLVM-exception` (OSI-approved,
|
||||
FSF-free, strictly Apache-2.0 plus an extra patent grant — outside spec
|
||||
02 §6's deny set). Added to `deny.toml` with rationale; `cargo deny
|
||||
check licenses bans sources` green.
|
||||
|
||||
## Decision
|
||||
|
||||
`script-sandbox` links `wasmtime =26.0.0` (minimal features) + the frozen
|
||||
closure only. `studio-cli` gains zero new dependencies. `engine-runtime`
|
||||
gains zero new dependencies (console-boundary proof:
|
||||
`cargo build -p engine-runtime --no-default-features` clean). QuickJS/LuaJIT
|
||||
bindings, `zip` archives, `wasm-bindgen` lowering, and native AOT cross-link
|
||||
defer to the toolchain-bump ADR with no boundary redesign.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Sandbox limits are spec-06-verbatim (fuel 10M, 64 MiB, 50 ms epoch) on a
|
||||
supported Wasmtime; `script_roundtrip_5k` measures ~0.28 ms (budget 1 ms).
|
||||
- Tripwire: re-vet `wasmtime latest` on every toolchain bump; the 26.0.0 pin
|
||||
lifts only together with the frozen `serde`/`indexmap` pins.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Phase 5 perf record — Scripting Runtime & Packager (spec 06)
|
||||
|
||||
Date: 2026-10-13. Toolchain: rustc 1.82.0. Host: i9-14900HX, 62 GiB RAM,
|
||||
Linux x86_64 (32 cores). Wasmtime 26.0.0 (minimal features; ADR 0003).
|
||||
|
||||
## Acceptance (all green)
|
||||
|
||||
| Check | Command | Result |
|
||||
|---|---|---|
|
||||
| Workspace build | `cargo build --workspace --locked` | ok, 0 errors |
|
||||
| Console boundary | `cargo build -p engine-runtime --no-default-features` | ok |
|
||||
| Sandbox tests | `cargo test -p script-sandbox` | 26 passed (fuel/OOM/epoch/socket/libm/handle/rng/sort/math-bounds) |
|
||||
| Round-trip bench | `cargo bench -p script-sandbox --bench script_roundtrip` | **p50 ~0.28 ms @ 5k entities** (budget ≤ 1 ms/frame) |
|
||||
| Fuel trap | `studio-cli script-test --fuel 10000000 --mem-mib 64 --no-net --epoch-ms 50 fixtures/loop.wasm` | `E_SANDBOX OutOfFuel`, exit 1, host unaffected |
|
||||
| Hot-swap | `studio-cli hot-swap-demo --scenario scenarios/qa_boss_rush.ron` | **swap 35 ms** (budget 100 ms), socket-swap rejected w/ old live, 10/10 replay hashes match |
|
||||
| Desktop pack | `studio-cli package --target x86_64-unknown-linux-gnu --scene samples/2d_platformer && ./dist/game --headless-frames 120` | ok, 120 frames replay exit 0 |
|
||||
| Web pack | `studio-cli package --target wasm32-unknown-unknown --scene samples/2d_platformer && ls dist/*.html dist/*.wasm` | ok, payload 909 bytes (budget 256 MiB), `SUBGROUPS_OFF=1` |
|
||||
| Cross manifests | `package --target x86_64-windows-msvc / aarch64-linux-android` | manifest-only ok (AOT link deferred, layout final) |
|
||||
| Repo gates | `studio-cli check` (fmt+clippy+deny+dep-tree) | all ok |
|
||||
| Lock hygiene | `git diff Cargo.lock` | +47 packages, −0, 0 version changes; all Phase-2/3/4 pins frozen |
|
||||
|
||||
## Phase-4 tripwires re-verified (no regress)
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `gate-demo` | PASS 5/5, total 24.1 s (< 60 s budget) |
|
||||
| `gate-negative-tests` | PASS 5/5 reject with structured errors |
|
||||
| `replay --ticks 3600 --expect-hashes` | 60/60 match |
|
||||
| `render-test 2d_platformer 120f` (proxy) | p50 **1.26 ms**, diff **0.0000%** (< 0.5%) |
|
||||
|
||||
## Deferred items — disposition (never silently dropped)
|
||||
|
||||
1. **Tier-1 1440p tracy capture** (cluster ≤ 0.6 ms, opaque ≤ 6 ms @ 1440p).
|
||||
**TRIPWIRE CLOSED on proxy evidence, native capture RE-DEFERRED:** no
|
||||
Tier-1 GPU on this host (software GL only). Fresh proxy re-verified this
|
||||
phase: 2D p50 1.26 ms, diff 0.0000%; `profile`-feature tracy spans
|
||||
(`sim`/`physics`/`extract`/`render`) exist and fire. Owner: render
|
||||
maintainer; re-probe on Tier-1 hardware or any render-path change.
|
||||
2. **Cold slangc ~508 ms persistent-session daemon.** Unchanged since Phase
|
||||
4 (owner render maintainer, tripwire quarterly + on Slang bump).
|
||||
3. **`winit`/`jsonrpsee`/`notify-8`/`gltf`/`symphonia` links.** Unchanged —
|
||||
deferred to toolchain-bump ADR (scratch-crate vet failures on record).
|
||||
4. **BYOK weights** (`tools/ai-sidecars/models/` still empty; gate-demo runs
|
||||
the deterministic synthesis path, 24.1 s). Still open: drop
|
||||
Qwen2.5-Coder-7B Q4 GGUF and re-run gate-demo to close.
|
||||
5. **QuickJS/LuaJIT bindings, zip archives, wasm-bindgen lowering, AOT
|
||||
cross-link.** New Phase-5 defers, recorded in ADR 0003 with same-DTO
|
||||
upgrade paths; re-vet on toolchain bump.
|
||||
6. **tree-sitter/deno CLI passthrough.** Unchanged (CI image still lacks
|
||||
them; same `SingleGate.command` rows, no re-design).
|
||||
|
||||
## Notes
|
||||
|
||||
- `script_roundtrip_5k` methodology: guest compiles once (load/swap time),
|
||||
each iteration instantiates with fresh fuel + runs one tick — steady-state
|
||||
cross+memcpy only. First bench run caught a real bug (per-tick watchdog
|
||||
threads cross-trapping later ticks via the shared epoch); fixed with a
|
||||
disarming single-shot guard (`EpochWatchdog`).
|
||||
- `fixtures/*.wasm` are committed binaries built once from auditable WAT
|
||||
(sources in the phase record, not in-repo); `wat` is not a workspace dep.
|
||||
- `dist/` is gitignored; desktop runner is 237 MB debug (documented in the
|
||||
manifest with content hashes — release-strip lands with the release
|
||||
profile, not silently).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user