diff --git a/Cargo.lock b/Cargo.lock
index 4dc9c7fa1..24f1b0d51 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3843,6 +3843,13 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "hyperion-platform"
+version = "0.1.0"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "hyperion-proto"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index a0e018cdc..25a096896 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ members = [
'crates/hyperion-packet-macros',
'crates/hyperion-palette',
'crates/hyperion-permission',
+ 'crates/hyperion-platform',
'crates/hyperion-proto',
'crates/hyperion-proxy',
'crates/hyperion-proxy-module',
@@ -195,6 +196,9 @@ path = 'crates/hyperion-palette'
[workspace.dependencies.hyperion-permission]
path = 'crates/hyperion-permission'
+[workspace.dependencies.hyperion-platform]
+path = 'crates/hyperion-platform'
+
[workspace.dependencies.hyperion-proto]
path = 'crates/hyperion-proto'
diff --git a/bare-metal/Cargo.lock b/bare-metal/Cargo.lock
new file mode 100644
index 000000000..27c459ad1
--- /dev/null
+++ b/bare-metal/Cargo.lock
@@ -0,0 +1,1302 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener",
+ "futures-core",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand 2.5.0",
+ "futures-lite 2.6.1",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bevy_ecs"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7709fbd22f81fb681534cd913c41e1cd18b17143368743281195d7f024b61aea"
+dependencies = [
+ "async-channel",
+ "bevy_ecs_macros",
+ "bevy_ptr",
+ "bevy_tasks",
+ "bevy_utils",
+ "downcast-rs",
+ "event-listener",
+ "fixedbitset",
+ "rustc-hash",
+ "serde",
+ "thiserror",
+ "thread_local",
+]
+
+[[package]]
+name = "bevy_ecs_macros"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8843aa489f159f25cdcd9fee75cd7d221a7098a71eaa72cb2d6b40ac4e3f1ba"
+dependencies = [
+ "bevy_macro_utils",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bevy_macro_utils"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e566640c6b6dced73d2006c764c2cffebe1a82be4809486c4a5d7b4b50efed4d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "syn 2.0.119",
+ "toml_edit",
+]
+
+[[package]]
+name = "bevy_ptr"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77ec20c8fafcdc196508ef5ccb4f0400a8d193cb61f7b14a36ed9a25ad423cf"
+
+[[package]]
+name = "bevy_tasks"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4fefa7fe0da8923525f7500e274f1bd60dbd79918a25cf7d0dfa0a6ba15c1cf"
+dependencies = [
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "concurrent-queue",
+ "futures-lite 1.13.0",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "bevy_utils"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7915222f4a08ccc782e08d10b751b42e5f9d786e697d0cb3fd09333cb7e8b6ea"
+dependencies = [
+ "ahash",
+ "bevy_utils_proc_macros",
+ "getrandom 0.2.17",
+ "hashbrown 0.14.5",
+ "instant",
+ "nonmax",
+ "petgraph",
+ "thiserror",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "bevy_utils_proc_macros"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aafecc952b6b8eb1a93c12590bd867d25df2f4ae1033a01dfdfc3c35ebccfff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bitfield-struct"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2869c63ccf4f8bf0d485070b880e60e097fb7aeea80ee82a0a94a957e372a0b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
+[[package]]
+name = "fastrand"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+dependencies = [
+ "instant",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-lite"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
+dependencies = [
+ "fastrand 1.9.0",
+ "futures-core",
+ "futures-io",
+ "memchr",
+ "parking",
+ "pin-project-lite",
+ "waker-fn",
+]
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand 2.5.0",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "glam"
+version = "0.29.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+ "allocator-api2",
+ "serde",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit"
+version = "0.13.2"
+source = "git+https://github.com/hermit-os/hermit-rs?tag=hermit-0.13.2#0e68850e7b848656f8704b6c1fdc3a09685cb4de"
+dependencies = [
+ "cc",
+ "home",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "hyperion-platform"
+version = "0.1.0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "hyperion-unikernel"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "hermit",
+ "hyperion-platform",
+ "valence_bytes",
+ "valence_protocol",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "nonmax"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+
+[[package]]
+name = "toml_edit"
+version = "0.20.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valence_build_utils"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "anyhow",
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "valence_bytes"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "bytes",
+]
+
+[[package]]
+name = "valence_generated"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "anyhow",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "uuid",
+ "valence_build_utils",
+ "valence_ident",
+ "valence_math",
+]
+
+[[package]]
+name = "valence_ident"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "bytes",
+ "serde",
+ "thiserror",
+ "valence_bytes",
+ "valence_ident_macros",
+]
+
+[[package]]
+name = "valence_ident_macros"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "valence_math"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "glam",
+]
+
+[[package]]
+name = "valence_nbt"
+version = "0.8.0"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "byteorder",
+ "cesu8",
+ "uuid",
+]
+
+[[package]]
+name = "valence_protocol"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "anyhow",
+ "base64",
+ "bevy_ecs",
+ "bitfield-struct",
+ "byteorder",
+ "bytes",
+ "derive_more",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tracing",
+ "url",
+ "uuid",
+ "valence_bytes",
+ "valence_generated",
+ "valence_ident",
+ "valence_math",
+ "valence_nbt",
+ "valence_protocol_macros",
+ "valence_text",
+]
+
+[[package]]
+name = "valence_protocol_macros"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "valence_text"
+version = "0.2.0-alpha.1+mc.1.20.1"
+source = "git+https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb6669b64c5dc2366eb3d074b293def046"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror",
+ "uuid",
+ "valence_ident",
+ "valence_nbt",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "waker-fn"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/bare-metal/Cargo.toml b/bare-metal/Cargo.toml
new file mode 100644
index 000000000..6d5806c8e
--- /dev/null
+++ b/bare-metal/Cargo.toml
@@ -0,0 +1,27 @@
+# A workspace of its own, on purpose.
+#
+# The unikernel binary needs the Hermit kernel as a git dependency and a
+# different nightly from the one the main workspace pins, and neither belongs in
+# the lockfile every normal build resolves. Keeping it separate means a plain
+# `cargo build` at the repo root neither sees nor fetches any of it.
+
+[workspace]
+members = ["hyperion-unikernel"]
+resolver = "2"
+
+[workspace.package]
+edition = "2024"
+license = "Apache-2.0"
+publish = false
+repository = "https://github.com/andrewgazelka/hyperion"
+version = "0.1.0"
+
+[workspace.lints.clippy]
+complexity = { level = "deny", priority = -1 }
+nursery = { level = "deny", priority = -1 }
+pedantic = { level = "deny", priority = -1 }
+perf = { level = "deny", priority = -1 }
+style = { level = "deny", priority = -1 }
+suspicious = { level = "deny", priority = -1 }
+
+missing_errors_doc = "allow"
diff --git a/bare-metal/hyperion-unikernel/Cargo.toml b/bare-metal/hyperion-unikernel/Cargo.toml
new file mode 100644
index 000000000..8312cc4c0
--- /dev/null
+++ b/bare-metal/hyperion-unikernel/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+edition.workspace = true
+license.workspace = true
+name = "hyperion-unikernel"
+publish.workspace = true
+repository.workspace = true
+version.workspace = true
+
+[dependencies]
+anyhow = "1.0.98"
+bytes = "1.8.0"
+hyperion-platform = { path = "../../crates/hyperion-platform" }
+
+[dependencies.valence_bytes]
+branch = "feat-bytes"
+git = "https://github.com/TestingPlant/valence"
+
+[dependencies.valence_protocol]
+branch = "feat-bytes"
+default-features = false
+git = "https://github.com/TestingPlant/valence"
+
+# Linking the kernel is what turns this from an application into a machine
+# image. On a hosted OS the same source builds as an ordinary binary.
+[target.'cfg(target_os = "hermit")'.dependencies.hermit]
+default-features = false
+features = ["acpi", "dhcpv4", "pci", "pci-ids", "smp", "tcp", "virtio-net"]
+git = "https://github.com/hermit-os/hermit-rs"
+tag = "hermit-0.13.2"
+
+[lints]
+workspace = true
diff --git a/bare-metal/hyperion-unikernel/src/main.rs b/bare-metal/hyperion-unikernel/src/main.rs
new file mode 100644
index 000000000..dfe9e2a34
--- /dev/null
+++ b/bare-metal/hyperion-unikernel/src/main.rs
@@ -0,0 +1,203 @@
+//! Hyperion's protocol layer, on a machine with no operating system.
+//!
+//! This answers a Minecraft client's server-list ping using the same
+//! `valence_protocol` codec the real server uses, over TCP, on whatever
+//! platform it was built for. On Linux that is an ordinary process. On
+//! `x86_64-unknown-hermit` it is a unikernel image: the binary *is* the kernel,
+//! it boots under QEMU, brings up virtio-net, takes a DHCP lease, and listens.
+//!
+//! It is a demonstration, not a server. What it proves is narrow and specific:
+//! that the wire protocol, the codec and the platform seam all work with no OS
+//! underneath, which is the part of hyperion nobody had established. It does
+//! not log anyone in — see `docs/bare-metal.md` for why the rest of the server
+//! does not build yet.
+
+// Linking the kernel is a side effect of the dependency existing; nothing calls
+// into it directly.
+#[cfg(target_os = "hermit")]
+use hermit as _;
+
+use std::{
+ io::{Read, Write},
+ net::{SocketAddr, TcpStream},
+};
+
+use anyhow::{Context, bail, ensure};
+use hyperion_platform::{CAPABILITIES, clock, net, parallelism};
+use valence_bytes::CowUtf8Bytes;
+use valence_protocol::{
+ DecodeBytes, Encode, MAX_PACKET_SIZE, PROTOCOL_VERSION, Packet, PacketEncoder,
+ bytes::Bytes,
+ packets::{
+ handshaking::{HandshakeC2s, handshake_c2s::HandshakeNextState},
+ status::{QueryPingC2s, QueryPongS2c, QueryRequestC2s, QueryResponseS2c},
+ },
+};
+
+/// Where to listen unless `HYPERION_PORT` says otherwise.
+///
+/// The environment is the only configuration channel that works on both
+/// platforms: a unikernel has no config file to read, but Hermit does populate
+/// `std::env` from the boot command line.
+const DEFAULT_PORT: u16 = 25565;
+
+fn port() -> u16 {
+ std::env::var("HYPERION_PORT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(DEFAULT_PORT)
+}
+
+fn main() -> anyhow::Result<()> {
+ let started = clock::monotonic();
+
+ println!("[hyperion] platform: {}", hyperion_platform::NAME);
+ println!("[hyperion] capabilities: {CAPABILITIES:?}");
+ println!("[hyperion] parallelism: {}", parallelism::available());
+ match clock::wall_clock() {
+ Some(t) => println!("[hyperion] wall clock: {t:?}"),
+ None => println!("[hyperion] wall clock: unavailable on this platform"),
+ }
+
+ let addr = SocketAddr::from(([0, 0, 0, 0], port()));
+ let listener = net::bind_tcp(addr)?;
+ println!(
+ "[hyperion] listening on {addr} after {elapsed:?}",
+ elapsed = started.elapsed()
+ );
+
+ for stream in listener.incoming() {
+ let stream = stream.context("accept failed")?;
+ let peer = stream.peer_addr().ok();
+ // One connection at a time. A status ping is three packets and
+ // concurrency is not the thing under test here.
+ if let Err(e) = serve(stream) {
+ println!("[hyperion] {peer:?}: {e:#}");
+ }
+ }
+
+ Ok(())
+}
+
+/// Handshake, status, ping. The whole server-list exchange.
+fn serve(mut stream: TcpStream) -> anyhow::Result<()> {
+ println!("[hyperion] accepted {peer:?}", peer = stream.peer_addr());
+
+ let handshake: HandshakeC2s<'_> = read_packet(&mut stream)?;
+ println!(
+ "[hyperion] handshake: protocol={protocol} host={host} port={port} next={next:?}",
+ protocol = handshake.protocol_version.0,
+ host = handshake.server_address.0.as_str(),
+ port = handshake.server_port,
+ next = handshake.next_state
+ );
+
+ ensure!(
+ handshake.next_state == HandshakeNextState::Status,
+ "only the status handshake is implemented on this platform"
+ );
+
+ let _: QueryRequestC2s = read_packet(&mut stream)?;
+
+ let json = status_json();
+ write_packet(
+ &mut stream,
+ &QueryResponseS2c {
+ json: CowUtf8Bytes::Borrowed(&json),
+ },
+ )?;
+ println!("[hyperion] sent status");
+
+ // A client measures latency from this round trip. Echoing the payload
+ // unchanged is the whole protocol.
+ let ping: QueryPingC2s = read_packet(&mut stream)?;
+ write_packet(
+ &mut stream,
+ &QueryPongS2c {
+ payload: ping.payload,
+ },
+ )?;
+ println!("[hyperion] ponged {payload:#x}", payload = ping.payload);
+
+ Ok(())
+}
+
+/// The server-list entry a client renders.
+fn status_json() -> String {
+ format!(
+ r#"{{"version":{{"name":"hyperion/{name}","protocol":{PROTOCOL_VERSION}}},"players":{{"max":10000,"online":0,"sample":[]}},"description":{{"text":"hyperion on {name}, no operating system"}}}}"#,
+ name = hyperion_platform::NAME
+ )
+}
+
+/// Read one length-prefixed frame and decode it as `P`.
+///
+/// `valence_protocol` keeps its framing decoder private and hyperion's own
+/// lives in a crate that does not build for this target yet, so the framing is
+/// open-coded here. It is only correct because compression is off: with a
+/// threshold set, the body carries a second length that this does not read.
+fn read_packet
(stream: &mut TcpStream) -> anyhow::Result
+where
+ P: Packet + DecodeBytes,
+{
+ let len = read_var_int(stream).context("packet length")?;
+ ensure!(
+ (0..=MAX_PACKET_SIZE).contains(&len),
+ "packet length {len} out of bounds"
+ );
+
+ let mut body = vec![0u8; usize::try_from(len).expect("length is non-negative")];
+ stream.read_exact(&mut body).context("packet body")?;
+ let mut body = Bytes::from(body);
+
+ let id = read_var_int(&mut body.as_ref()).context("packet id")?;
+ let id_len = var_int_len(id);
+ let _ = body.split_to(id_len);
+
+ ensure!(
+ id == P::ID,
+ "expected {name} (id {expected}), got id {id}",
+ name = P::NAME,
+ expected = P::ID
+ );
+
+ let packet = P::decode_bytes(&mut body).context("decode failed")?;
+ ensure!(
+ body.is_empty(),
+ "{name}: {left} trailing bytes",
+ name = P::NAME,
+ left = body.len()
+ );
+ Ok(packet)
+}
+
+/// Read a Minecraft `VarInt`: seven bits per byte, little end first, high bit
+/// meaning "another byte follows", five bytes maximum.
+fn read_var_int(r: &mut impl Read) -> anyhow::Result {
+ let mut value = 0i32;
+ for shift in 0..5 {
+ let mut byte = [0u8; 1];
+ r.read_exact(&mut byte).context("short read")?;
+ value |= i32::from(byte[0] & 0x7F) << (shift * 7);
+ if byte[0] & 0x80 == 0 {
+ return Ok(value);
+ }
+ }
+ bail!("`VarInt` longer than five bytes")
+}
+
+/// How many bytes the wire form of `value` occupies.
+const fn var_int_len(value: i32) -> usize {
+ let bits = 32 - (value.cast_unsigned() | 1).leading_zeros();
+ (bits as usize).div_ceil(7)
+}
+
+fn write_packet(stream: &mut TcpStream, packet: &P) -> anyhow::Result<()>
+where
+ P: Packet + Encode,
+{
+ let mut encoder = PacketEncoder::new();
+ encoder.append_packet(packet)?;
+ stream.write_all(&encoder.take()).context("write failed")?;
+ stream.flush().context("flush failed")
+}
diff --git a/bare-metal/rust-toolchain.toml b/bare-metal/rust-toolchain.toml
new file mode 100644
index 000000000..12513607a
--- /dev/null
+++ b/bare-metal/rust-toolchain.toml
@@ -0,0 +1,11 @@
+# Pinned to the Hermit kernel's own nightly, and it has to match.
+#
+# The kernel is compiled by a nested cargo invocation and linked into the
+# application, so application `std` and kernel implement two halves of the same
+# `hermit-abi`. Building the application on the main workspace's
+# nightly-2025-02-22 produces an image that boots, prints, and then fails every
+# socket call with an uncategorised error, because that std was built against
+# hermit-abi 0.4 while kernel 0.13.2 answers 0.5. See docs/bare-metal.md.
+[toolchain]
+channel = "nightly-2026-07-01"
+components = ["rust-src", "llvm-tools", "rustfmt", "clippy"]
diff --git a/crates/hyperion-platform/Cargo.toml b/crates/hyperion-platform/Cargo.toml
new file mode 100644
index 000000000..8ead9697d
--- /dev/null
+++ b/crates/hyperion-platform/Cargo.toml
@@ -0,0 +1,26 @@
+# Package fields are spelled out rather than inherited, and the lint table is
+# local, because this crate is a member of two workspaces: the main one, and
+# bare-metal/, which builds it for a target the main lockfile does not know
+# about. `x.workspace = true` only resolves against one root.
+[package]
+authors = ["Andrew Gazelka "]
+edition = "2024"
+license = "Apache-2.0"
+name = "hyperion-platform"
+publish = false
+readme = "README.md"
+repository = "https://github.com/andrewgazelka/hyperion"
+version = "0.1.0"
+
+[dependencies]
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2.172"
+
+[lints.clippy]
+complexity = { level = "deny", priority = -1 }
+nursery = { level = "deny", priority = -1 }
+pedantic = { level = "deny", priority = -1 }
+perf = { level = "deny", priority = -1 }
+style = { level = "deny", priority = -1 }
+suspicious = { level = "deny", priority = -1 }
diff --git a/crates/hyperion-platform/README.md b/crates/hyperion-platform/README.md
new file mode 100644
index 000000000..ebcac7c64
--- /dev/null
+++ b/crates/hyperion-platform/README.md
@@ -0,0 +1,22 @@
+# hyperion-platform
+
+The set of operating-system services hyperion needs, behind one narrow module
+surface, so that a target without an OS is a backend rather than a fork.
+
+Five things differ between a hosted OS and a unikernel, and only these five:
+
+| module | hosted (Linux, macOS) | unikernel (Hermit) |
+| ------------- | --------------------------- | --------------------------------- |
+| `limits` | `setrlimit(RLIMIT_NOFILE)` | no such limit; reports the cap |
+| `clock` | monotonic + wall clock | monotonic; wall clock may be absent |
+| `storage` | the filesystem | RAM, seeded by the image |
+| `net` | `std::net` + `AF_UNIX` | `std::net` over virtio-net only |
+| `parallelism` | `available_parallelism` | vCPUs handed over at boot |
+
+Everything else hyperion does is arithmetic, and needs no seam.
+
+The backend is picked by `cfg(target_os)` at compile time. Hosted is the
+default and the only one a normal build ever sees, so adding a third platform
+means writing a `backend/*.rs` and one `cfg` arm, not editing call sites.
+
+See `docs/bare-metal.md` for what actually builds on the unikernel today.
diff --git a/crates/hyperion-platform/src/backend.rs b/crates/hyperion-platform/src/backend.rs
new file mode 100644
index 000000000..1333b6bd1
--- /dev/null
+++ b/crates/hyperion-platform/src/backend.rs
@@ -0,0 +1,15 @@
+//! Backend selection.
+//!
+//! One `cfg` arm per platform, and nothing else in the crate branches on the
+//! target. Adding a platform is: write a module, add an arm here, add a row to
+//! the table in the README.
+
+#[cfg(target_os = "hermit")]
+mod unikernel;
+#[cfg(target_os = "hermit")]
+pub use unikernel::*;
+
+#[cfg(not(target_os = "hermit"))]
+mod hosted;
+#[cfg(not(target_os = "hermit"))]
+pub use hosted::*;
diff --git a/crates/hyperion-platform/src/backend/hosted.rs b/crates/hyperion-platform/src/backend/hosted.rs
new file mode 100644
index 000000000..2add1d60a
--- /dev/null
+++ b/crates/hyperion-platform/src/backend/hosted.rs
@@ -0,0 +1,95 @@
+//! Linux, macOS, and anything else with an operating system under it.
+//!
+//! This is the default backend and the only one a normal build compiles. Every
+//! function here is the behaviour hyperion had before the seam existed.
+
+use std::{
+ fs, io,
+ num::NonZeroUsize,
+ path::Path,
+ sync::OnceLock,
+ time::SystemTime,
+};
+
+use crate::{Capabilities, storage::Store};
+
+pub const NAME: &str = crate::HOSTED;
+
+pub const CAPABILITIES: Capabilities = Capabilities {
+ persistent_storage: true,
+ unix_sockets: cfg!(unix),
+ dns: true,
+ trustworthy_wall_clock: true,
+ adjustable_file_limit: cfg!(unix),
+ subprocesses: true,
+};
+
+#[cfg(unix)]
+pub fn raise_open_files(recommended_min: u64) -> io::Result {
+ // Initialised by getrlimit; the zeroes are never read.
+ let mut limits = libc::rlimit {
+ rlim_cur: 0,
+ rlim_max: 0,
+ };
+
+ // SAFETY: `limits` is a live, correctly typed rlimit for the duration of
+ // the call, and RLIMIT_NOFILE is a valid resource.
+ if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limits) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+
+ limits.rlim_cur = limits.rlim_max;
+
+ // SAFETY: as above, and rlim_cur <= rlim_max so the request is valid.
+ if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const limits) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+
+ let _ = recommended_min;
+ Ok(limits.rlim_cur)
+}
+
+#[cfg(not(unix))]
+pub fn raise_open_files(recommended_min: u64) -> io::Result {
+ Ok(recommended_min)
+}
+
+// The seam's signature, not this backend's: a hosted OS always has an
+// answer, but the caller must still handle the platform that does not.
+#[expect(clippy::unnecessary_wraps, reason = "signature is fixed by the seam")]
+pub fn wall_clock() -> Option {
+ Some(SystemTime::now())
+}
+
+pub fn available_parallelism() -> NonZeroUsize {
+ std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN)
+}
+
+/// The filesystem, reached through the blob-shaped [`Store`] interface.
+struct FsStore;
+
+impl Store for FsStore {
+ fn read(&self, key: &Path) -> io::Result> {
+ fs::read(key)
+ }
+
+ fn write(&self, key: &Path, bytes: &[u8]) -> io::Result<()> {
+ if let Some(parent) = key.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ fs::write(key, bytes)
+ }
+
+ fn exists(&self, key: &Path) -> bool {
+ key.exists()
+ }
+
+ fn is_persistent(&self) -> bool {
+ true
+ }
+}
+
+pub fn store() -> &'static dyn Store {
+ static STORE: OnceLock = OnceLock::new();
+ STORE.get_or_init(|| FsStore)
+}
diff --git a/crates/hyperion-platform/src/backend/unikernel.rs b/crates/hyperion-platform/src/backend/unikernel.rs
new file mode 100644
index 000000000..eebc330bb
--- /dev/null
+++ b/crates/hyperion-platform/src/backend/unikernel.rs
@@ -0,0 +1,95 @@
+//! Hermit, and any future unikernel that supplies a `std`.
+//!
+//! Everything here is either a constant the hypervisor fixed at boot or an
+//! honest refusal. Nothing pretends.
+
+use std::{
+ collections::HashMap,
+ io,
+ num::NonZeroUsize,
+ path::{Path, PathBuf},
+ sync::{Mutex, OnceLock},
+ time::SystemTime,
+};
+
+use crate::{Capabilities, storage::Store};
+
+pub const NAME: &str = crate::UNIKERNEL;
+
+pub const CAPABILITIES: Capabilities = Capabilities {
+ // The image's RAM is all there is, and it goes away with the VM.
+ persistent_storage: false,
+ // smoltcp speaks IP. There is no filesystem to hang a socket off anyway.
+ unix_sockets: false,
+ // Hermit resolves names only when built with its `dns` feature, and this
+ // crate cannot see the kernel's feature set from here. Assume not, and let
+ // a build that knows better say so.
+ dns: false,
+ // No RTC unless the hypervisor provides one, and QEMU's default guest boots
+ // believing it is the epoch.
+ trustworthy_wall_clock: false,
+ adjustable_file_limit: false,
+ // Hermit has no process model at all: `std::process` is the `unsupported`
+ // backend, so `Command::spawn` always errors.
+ subprocesses: false,
+};
+
+pub fn raise_open_files(recommended_min: u64) -> io::Result {
+ // There is no rlimit here. The socket table is sized when the kernel is
+ // built, so the honest answer is "whatever you asked for, nothing stopped
+ // you", and the caller's warning threshold never fires spuriously.
+ Ok(recommended_min)
+}
+
+pub fn wall_clock() -> Option {
+ None
+}
+
+pub fn available_parallelism() -> NonZeroUsize {
+ // Hermit implements this over the vCPU count the hypervisor passed at boot,
+ // so unlike on Linux it is exact rather than a hint.
+ std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN)
+}
+
+/// RAM pretending to be a disk.
+///
+/// Reads of anything the image did not ship with fail as not-found, which is
+/// the same failure a hosted build gets from a missing file, so callers that
+/// already handle a missing config need no unikernel-specific branch.
+struct MemStore {
+ entries: Mutex>>,
+}
+
+impl Store for MemStore {
+ fn read(&self, key: &Path) -> io::Result> {
+ self.entries
+ .lock()
+ .expect("store poisoned")
+ .get(key)
+ .cloned()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{}", key.display())))
+ }
+
+ fn write(&self, key: &Path, bytes: &[u8]) -> io::Result<()> {
+ self.entries
+ .lock()
+ .expect("store poisoned")
+ .insert(key.to_path_buf(), bytes.to_vec());
+ Ok(())
+ }
+
+ fn exists(&self, key: &Path) -> bool {
+ self.entries.lock().expect("store poisoned").contains_key(key)
+ }
+
+ fn is_persistent(&self) -> bool {
+ false
+ }
+}
+
+pub fn store() -> &'static dyn Store {
+ static STORE: OnceLock = OnceLock::new();
+ STORE.get_or_init(|| MemStore {
+ entries: Mutex::new(HashMap::new()),
+ })
+}
diff --git a/crates/hyperion-platform/src/clock.rs b/crates/hyperion-platform/src/clock.rs
new file mode 100644
index 000000000..7aabd00c9
--- /dev/null
+++ b/crates/hyperion-platform/src/clock.rs
@@ -0,0 +1,24 @@
+//! Time.
+//!
+//! Monotonic time exists everywhere. Wall-clock time does not: a unikernel
+//! booted without an RTC or a hypervisor time source has no idea what year it
+//! is, so [`wall_clock`] is fallible rather than silently wrong.
+
+use std::time::{Instant, SystemTime};
+
+/// A monotonic instant. Always available.
+#[must_use]
+pub fn monotonic() -> Instant {
+ Instant::now()
+}
+
+/// The current wall-clock time, or `None` where the platform has no trustworthy
+/// source for it.
+///
+/// Anything that stamps a durable record — a ban, a statistic, a log shipped
+/// off the machine — should handle the `None` case rather than substituting
+/// the epoch.
+#[must_use]
+pub fn wall_clock() -> Option {
+ crate::backend::wall_clock()
+}
diff --git a/crates/hyperion-platform/src/lib.rs b/crates/hyperion-platform/src/lib.rs
new file mode 100644
index 000000000..b2c1c362b
--- /dev/null
+++ b/crates/hyperion-platform/src/lib.rs
@@ -0,0 +1,64 @@
+//! The operating-system services hyperion needs, behind one narrow surface, so
+//! that a target without an OS is a backend rather than a fork.
+//!
+//! This is deliberately not a `no_std` crate. The bare-metal target hyperion
+//! aims at is a unikernel that supplies a real `std`, so the seam is not about
+//! the absence of a standard library; it is about the handful of things `std`
+//! exposes that a machine with no operating system underneath cannot honour.
+//! The survey in `docs/bare-metal.md` found five, and they are the modules
+//! below. Everything else hyperion does is arithmetic and needs no seam.
+//!
+//! The backend is chosen by `cfg(target_os)`. [`HOSTED`] is the default and the
+//! only one a normal Linux or macOS build ever compiles, so adding a third
+//! platform means writing a backend module and one `cfg` arm rather than
+//! editing call sites.
+
+mod backend;
+
+pub mod clock;
+pub mod limits;
+pub mod net;
+pub mod parallelism;
+pub mod storage;
+
+/// A hosted operating system: a filesystem, a process model, and a full socket
+/// API. Linux and macOS.
+pub const HOSTED: &str = "hosted";
+
+/// A unikernel: the application is the kernel, and there is no filesystem, no
+/// process model, and no network beyond what the hypervisor hands over.
+pub const UNIKERNEL: &str = "unikernel";
+
+/// Which backend this build was compiled against, for logging and for tests
+/// that need to skip what the platform cannot do.
+pub const NAME: &str = backend::NAME;
+
+/// What the current platform can actually do.
+///
+/// Read these rather than testing `cfg(unix)` at a call site. A call site that
+/// asks "am I on Unix?" has to be revisited for every new platform; one that
+/// asks "is there a filesystem?" does not.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[non_exhaustive]
+// A capability set is a set of yes-or-no answers. Packing them into an enum or
+// a bitflag would only make call sites less readable.
+#[expect(clippy::struct_excessive_bools, reason = "this is a set of flags")]
+pub struct Capabilities {
+ /// Whether [`storage`] is backed by something that survives a reboot.
+ pub persistent_storage: bool,
+ /// Whether `AF_UNIX` sockets exist. The proxy's local transport needs them.
+ pub unix_sockets: bool,
+ /// Whether hostnames resolve. Without this, peers must be given as literal
+ /// addresses.
+ pub dns: bool,
+ /// Whether [`clock::wall_clock`] returns a time anyone should trust.
+ pub trustworthy_wall_clock: bool,
+ /// Whether the open-file limit is a thing that exists and can be raised.
+ pub adjustable_file_limit: bool,
+ /// Whether child processes can be spawned. Hermit has no process model at
+ /// all, so anything shelling out is a hard stop rather than a slow path.
+ pub subprocesses: bool,
+}
+
+/// The capabilities of the platform this build targets.
+pub const CAPABILITIES: Capabilities = backend::CAPABILITIES;
diff --git a/crates/hyperion-platform/src/limits.rs b/crates/hyperion-platform/src/limits.rs
new file mode 100644
index 000000000..d3cd53827
--- /dev/null
+++ b/crates/hyperion-platform/src/limits.rs
@@ -0,0 +1,18 @@
+//! Raising the open-file limit, where there is one.
+
+/// Raise the soft limit on open files as far as the hard limit allows, and
+/// report the limit now in force.
+///
+/// Ten thousand players at two file handles each is twenty thousand, and macOS
+/// still defaults to a soft limit of 256, so a hosted build has to ask. A
+/// unikernel has no such limit to raise: it reports the ceiling its socket
+/// table was built with and does nothing.
+///
+/// Callers should treat a returned value below `recommended_min` as a warning,
+/// not an error, because it is one.
+///
+/// # Errors
+/// Whatever the platform reports when reading or setting the limit fails.
+pub fn raise_open_files(recommended_min: u64) -> std::io::Result {
+ crate::backend::raise_open_files(recommended_min)
+}
diff --git a/crates/hyperion-platform/src/net.rs b/crates/hyperion-platform/src/net.rs
new file mode 100644
index 000000000..991d894e3
--- /dev/null
+++ b/crates/hyperion-platform/src/net.rs
@@ -0,0 +1,47 @@
+//! Sockets.
+//!
+//! Both platforms have `std::net`, so this module does not wrap it. What it
+//! does is name the two things that differ, so a call site can ask about the
+//! capability instead of the operating system.
+//!
+//! Hermit's stack is smoltcp over virtio-net. It gives IPv4 and IPv6 TCP and
+//! UDP, and nothing else: no `AF_UNIX`, and DNS only when the kernel was built
+//! with its resolver.
+
+pub use std::net::{TcpListener, TcpStream, UdpSocket};
+use std::{io, net::SocketAddr};
+
+/// Bind a TCP listener, naming the platform in the error.
+///
+/// A bare `bind` failure on a unikernel is an opaque `Uncategorized`, which
+/// sends people looking at their firewall rather than at the fact that the
+/// guest never got an address. Say which platform failed.
+///
+/// # Errors
+/// Whatever the platform's `bind` reports.
+pub fn bind_tcp(addr: SocketAddr) -> io::Result {
+ TcpListener::bind(addr).map_err(|e| {
+ io::Error::new(
+ e.kind(),
+ format!("{platform}: failed to bind {addr}: {e}", platform = crate::NAME),
+ )
+ })
+}
+
+/// Whether `AF_UNIX` sockets exist on this platform.
+///
+/// The proxy prefers a Unix socket for the server link when both ends share a
+/// machine. On a unikernel there is no such thing, and no filesystem to put one
+/// in, so the loopback path is the only path.
+#[must_use]
+pub const fn supports_unix_sockets() -> bool {
+ crate::CAPABILITIES.unix_sockets
+}
+
+/// Whether hostnames resolve.
+///
+/// When this is `false`, every peer address must already be a literal.
+#[must_use]
+pub const fn supports_dns() -> bool {
+ crate::CAPABILITIES.dns
+}
diff --git a/crates/hyperion-platform/src/parallelism.rs b/crates/hyperion-platform/src/parallelism.rs
new file mode 100644
index 000000000..ae4409796
--- /dev/null
+++ b/crates/hyperion-platform/src/parallelism.rs
@@ -0,0 +1,35 @@
+//! How many threads to run, and how to start them.
+
+use std::num::NonZeroUsize;
+
+/// How many threads can run at once.
+///
+/// On a hosted OS this is the CPU count as constrained by affinity and cgroup
+/// quota. On a unikernel it is the number of vCPUs the hypervisor handed over
+/// at boot, which is exact rather than a hint.
+#[must_use]
+pub fn available() -> NonZeroUsize {
+ crate::backend::available_parallelism()
+}
+
+/// Spawn a worker thread with an explicit stack size.
+///
+/// Hyperion sizes its rayon workers deliberately, and a unikernel's default
+/// stack is much smaller than Linux's 8 MiB, so the size is not optional here
+/// the way it is in [`std::thread::spawn`].
+///
+/// # Errors
+/// If the platform will not give us another thread.
+pub fn spawn_worker(
+ name: &str,
+ stack_size: usize,
+ f: F,
+) -> std::io::Result>
+where
+ F: FnOnce() + Send + 'static,
+{
+ std::thread::Builder::new()
+ .name(name.to_owned())
+ .stack_size(stack_size)
+ .spawn(f)
+}
diff --git a/crates/hyperion-platform/src/storage.rs b/crates/hyperion-platform/src/storage.rs
new file mode 100644
index 000000000..30daa7cf6
--- /dev/null
+++ b/crates/hyperion-platform/src/storage.rs
@@ -0,0 +1,44 @@
+//! Durable-ish bytes, keyed by path.
+//!
+//! Hyperion reads a TOML config, an LMDB player database, Anvil region files
+//! and a downloaded asset bundle, and every one of those assumes a filesystem.
+//! A unikernel has none unless the hypervisor attaches one, so this module is
+//! the seam: a hosted build gets the real filesystem, and a unikernel build
+//! gets RAM that starts out holding whatever the image was built with.
+//!
+//! The trait is intentionally blob-shaped rather than file-shaped. Nothing in
+//! hyperion needs `seek` or partial writes, and offering them would mean
+//! implementing a filesystem on the unikernel side.
+
+use std::{io, path::Path};
+
+/// A place to put bytes and get them back.
+pub trait Store: Send + Sync {
+ /// Read the whole value at `key`.
+ ///
+ /// # Errors
+ /// [`io::ErrorKind::NotFound`] if there is nothing at `key`.
+ fn read(&self, key: &Path) -> io::Result>;
+
+ /// Write `bytes` at `key`, replacing anything already there.
+ ///
+ /// # Errors
+ /// Whatever the backing store reports; a read-only backend returns
+ /// [`io::ErrorKind::Unsupported`].
+ fn write(&self, key: &Path, bytes: &[u8]) -> io::Result<()>;
+
+ /// Whether `key` holds anything.
+ fn exists(&self, key: &Path) -> bool;
+
+ /// Whether writes survive a restart. `false` means this is a cache.
+ fn is_persistent(&self) -> bool;
+}
+
+/// The store for this platform.
+///
+/// One process-wide instance, because the thing it stands in for — the
+/// filesystem — is also one process-wide instance.
+#[must_use]
+pub fn store() -> &'static dyn Store {
+ crate::backend::store()
+}
diff --git a/docs/bare-metal.md b/docs/bare-metal.md
new file mode 100644
index 000000000..164e8ca47
--- /dev/null
+++ b/docs/bare-metal.md
@@ -0,0 +1,397 @@
+# Hyperion with no operating system
+
+Experimental. Nothing here is on any normal build path, and none of it is
+merged.
+
+## What this achieved: the protocol layer boots on a unikernel and answers a real client
+
+`nix build .#bare-metal` produces an `x86_64-unknown-hermit` machine image;
+`nix run .#bare-metal-vm` boots it under QEMU, where it brings up virtio-net,
+takes a DHCP lease and answers a Minecraft server-list ping decoded with the
+same `valence_protocol` the real server uses. Real output is in
+[Evidence](#evidence).
+
+What that does **not** mean: the game server does not build. Eleven of
+twenty-four workspace crates compile for the target and thirteen do not,
+including `hyperion` itself. The gap and its causes are in
+[The blocker survey](#the-blocker-survey).
+
+## What to reach for: Hermit, the only live Rust unikernel with a real `std`
+
+The choice matters less than it looks, because only two candidates ship a
+`std`, and only one of them has virtio-net drivers you can read.
+
+| Option | Version / date checked 2026-07-27 | What it gives | What it costs |
+| --- | --- | --- | --- |
+| **Hermit** (`hermit-os/kernel`, `hermit-os/hermit-rs`) | kernel **v0.13.2**, 2026-03-13; both repos pushed 2026-07-27 | A genuine `std` behind four Tier-3 triples (`x86_64`, `aarch64`, `aarch64_be`, `riscv64gc`), upstream `library/std/src/sys/pal/hermit`. virtio-net, virtio-fs, virtio-vsock, smoltcp 0.13, DHCPv4 on by default. QEMU, Firecracker and uhyve in CI. | Tier 3, so `-Z build-std`. Kernel pins its own monthly nightly. `std::process` absent entirely; `std::fs` missing rename, symlink, canonicalize and all locking. **Tokio only through two unmaintained forks.** Nothing in nixpkgs. |
+| **Motor OS** (`moturus/motor-os`) | Tier-3 target `x86_64-unknown-motor` since 2025-10-17; pushed 2026-07-27 | Rust microkernel, VM-only, `std` upstream, smoltcp, ~100 ms boot, ~10 Gbps guest↔host. | No DHCP (static IPs only), Tokio only partly ported, essentially one maintainer, no security audit by its own account. |
+| **Unikraft** | v0.21.0, 2026-04/05; project healthy | Mature C unikernel; runs ordinary musl Rust binaries through its binary-compatibility layer. | **Its Rust integration is dead.** `unikraft/lib-rust` last pushed 2024-01-02; the catalog's Rust examples are all pinned to Rust 1.75 (Dec 2023); the `x86_64-unikraft-linux-musl` target's own rustc docs say linking needs a KraftKit shim and point at an issue closed in 2023 with no successor. |
+| **`no_std` + `virtio-drivers` + `smoltcp`** | `virtio-drivers` 0.13.0 (2026-03-03), `smoltcp` 0.13.1 (2026-04-30) | Works on stable against Tier-2 `x86_64-unknown-none`, no `build-std`, no custom JSON target. | No `std`, ever. You write the boot path, the allocator and the event loop. smoltcp was still fixing TCP panics in April 2026. |
+| **Nanos/OPS, OSv** | nanos 0.1.55 (2026-04-26); OSv last tag 2022-12-20, master alive | Run unmodified Linux ELFs, so "Rust support" is `--target x86_64-unknown-linux-musl`. | The kernel is an opaque C blob; no Rust-level control of the virtio stack. |
+| **Firecracker + minimal Linux** | v1.16.1, 2026-07-02 | The boring baseline, and **the only option where virtio-mem actually works** (guest driver landed 1.14.0; needs `CONFIG_VIRTIO_MEM`, Linux ≥5.16). | It is a Linux VM. Nothing bare-metal about the guest. |
+
+### virtio-mem does not exist in Rust guest land
+
+The brief assumed virtio-mem alongside virtio-net. It is not available on any
+Rust unikernel. Hermit's `src/drivers/` has `console`, `fs`, `net`, `vsock` and
+nothing else; the only matches for `virtio.mem` in the whole kernel tree are
+three occurrences of `virtio_mem_barrier`, a memory *barrier* helper. The
+`virtio-drivers` crate has no virtio-mem driver, no issue and no PR proposing
+one; ballooning is an open PR (#251, 2026-06-15) and unmerged. If elastic guest
+memory is a requirement, that is a from-scratch driver plus kernel allocator
+work, or it is Firecracker with a Linux guest.
+
+### `crates.io` is a dead end for Hermit, and says so
+
+```
+error: This crate is no longer distributed via crates.io. Use the crate via Git instead.
+ --> ~/.cargo/registry/src/index.crates.io-.../hermit-0.13.0/src/lib.rs:4:1
+```
+
+Both `hermit` and `hermit-kernel` on crates.io are `compile_error!` stubs. The
+dependency has to be a git tag.
+
+## The toolchain finding that costs a day if you meet it the hard way
+
+**Hyperion's pinned `nightly-2025-02-22` builds a Hermit image that boots,
+prints, and then fails every socket call.** It is not a networking problem and
+it does not look like a toolchain problem:
+
+```
+[ 0.409679][0][INFO hermit ] Jumping into application
+[app] hermit unikernel up
+
+thread 'main' panicked at src/main.rs:8:54:
+bind: Kind(Uncategorized)
+```
+
+The kernel and the application implement two halves of one `hermit-abi`. The
+application's `std` is compiled from that nightly's `rust-src`, which pulls
+`hermit-abi 0.4.0`; kernel 0.13.2 answers `hermit-abi 0.5`. Boot and `println!`
+happen to line up across that gap and sockets do not. Rebuilding the identical
+source on `nightly-2026-07-01` — the kernel's own pin — binds first try.
+
+So `bare-metal/` carries its own `rust-toolchain.toml`. Two toolchains in one
+repo is a cost, and the alternative is worse: moving the whole workspace onto a
+2026 nightly to satisfy a target nobody ships yet.
+
+`nightly-2025-02-22` is also simply too old for the current `hermit` crate's
+build dependencies, which is the failure you hit first:
+
+```
+error: rustc 1.87.0-nightly is not supported by the following package:
+ home@0.5.12 requires rustc 1.88
+```
+
+### Unstable features relied on
+
+Exactly one: `-Z build-std=std,panic_abort`, plus the `rust-src` and
+`llvm-tools` components. No feature gates in any source file, no custom JSON
+target. `-Z build-std` is a funded 2026 Rust Project Goal with RFC 3873
+accepted, but its goal page states that using `std` with *custom* targets is
+out of scope — builtin Tier-3 targets like Hermit's are the ones on the
+stabilisation path, which is the right side of that line to be on.
+
+There is a second route worth knowing: `hermit-os/rust-std-hermit` publishes an
+installable `rust-std` component tracking stable point releases (1.97.1,
+2026-07-20), which removes `-Z build-std` entirely. This branch does not use it,
+because it would pair a stable-built `std` with a nightly-built kernel — the
+exact pairing whose ABI drift is documented above.
+
+## The blocker survey: 11 of 24 crates compile, and four dependencies explain the rest
+
+Method, so the numbers can be read correctly:
+
+```sh
+RUSTFLAGS="--cfg tokio_unstable" cargo +nightly-2025-02-22 build \
+ -Z build-std=std,panic_abort --target x86_64-unknown-hermit -p
+```
+
+This is a *reachability* survey run per package, so a failure is the **first**
+wall, not a complete list: cargo stops at the first crate that will not build,
+and anything behind it is untested. Read a "blocked by socket2" row as "gets no
+further than socket2", not as "socket2 is the only problem".
+
+The survey ran on `nightly-2025-02-22` deliberately, to answer "what does the
+tree as it stands do", not "what could it do". Since compilation does not
+exercise the syscall ABI, the 2025 toolchain gives the same answer here as the
+2026 one would.
+
+### Compiles for `x86_64-unknown-hermit` today (11)
+
+`hyperion-minecraft-proto`, `hyperion-nerd-font`, `hyperion-scheduled`,
+`hyperion-stats`, `simd-utils`, `geometry`, `hyperion-proto`,
+`hyperion-palette`, `hyperion-text`, `hyperion-crafting`, `packet-channel`.
+
+Two of those are more interesting than they look. `hyperion-palette` pulls
+`valence_protocol` with the `compression` feature, so **the whole Minecraft
+protocol codec cross-compiles unmodified** — that is what made the demo
+possible. `hyperion-crafting` and `packet-channel` pull `bevy` with
+`multi_threaded`, so **bevy_ecs cross-compiles too**.
+
+### Does not compile (13)
+
+| Crate | Stops at | Why | Fixable how |
+| --- | --- | --- | --- |
+| `bvh-region` | `wait-timeout` | `proptest` is in `[dependencies]`, not `[dev-dependencies]`; it pulls `rusty-fork` → `wait-timeout`, which has a `sys` module for unix and windows only. | Move `proptest` to `[dev-dependencies]`. This is a bug on Linux too — it ships a test framework into release builds. |
+| `hyperion-utils` | `socket2`, `openssl-sys` | `reqwest` with default features on, so `default-tls` → `native-tls` → `openssl-sys`, despite `rustls-tls` also being requested. | `default-features = false` on `reqwest` removes openssl outright. `socket2` needs Hermit's fork. |
+| `hyperion-command`, `hyperion-gui`, `hyperion-item`, `hyperion-genmap`, `hyperion-clap`, `hyperion-permission` | `socket2`, `openssl-sys`, `libz-ng-sys`, `ring`, `wait-timeout` | All reach these through `hyperion` or `hyperion-utils`. None has a blocker of its own. | Fix the four below and re-survey. |
+| `hyperion-proxy`, `hyperion-proxy-module` | `socket2` | `tokio` with `net`. | Hermit's `socket2` and `tokio` forks. |
+| `hyperion` | `socket2`, `openssl-sys`, `libz-ng-sys`, `ring`, `wait-timeout` | Everything at once, plus more behind it. | See below. |
+| `bedwars` | as `hyperion`, plus `std::os::unix` | `error[E0433]: could not find 'unix' in 'os'`. | A platform seam call, which is what `hyperion-platform` is for. |
+
+The four dependencies that cause almost every failure:
+
+| Dependency | Reached via | Nature | Cost to fix |
+| --- | --- | --- | --- |
+| `socket2` | `tokio` (net), `reqwest` | `error: Socket2 doesn't support the compile target` | Low. `hermit-os/socket2` fork exists and is what hermit-rs patches in. |
+| `openssl-sys` | `reqwest` default features | C library, needs a cross toolchain | Low, and worth doing anyway: the tree asks for `rustls-tls` and gets openssl as well. |
+| `libz-ng-sys` | `flate2` with `zlib-ng` in `hyperion` | C library | Medium. Dropping to `flate2`'s pure-Rust `miniz_oxide` backend costs compression throughput on the hot egress path. |
+| `ring` | rustls crypto provider | C and assembly | Medium. `aws-lc-rs` is also C; `rustls` has no pure-Rust provider in this tree. |
+
+### Blockers the survey never reached, from reading the manifests
+
+These are **unverified**. Each sits behind one of the four above, so no build
+has ever gotten far enough to confirm or refute them. Listing them because they
+are the ones that decide whether the server can ever boot, not because they have
+been measured:
+
+| Dependency | Used by | Why it looks fatal |
+| --- | --- | --- |
+| `heed` (LMDB) | `hyperion` player DB, `hyperion-permission` | C library, `mmap`, file locking. Hermit's `std::fs` has no locking at all. |
+| `memmap2` | `hyperion` Anvil region reader | `mmap` of a file; there is no file. |
+| `libdeflater` | `hyperion` chunk compression | C library. |
+| `tikv-jemallocator` | `hyperion`, `bedwars` | C allocator; a unikernel supplies its own. Already `cfg`'d off on Windows, so the seam exists. |
+| `ndarray` with `blas` | `hyperion` | Requires a system BLAS. |
+| `tracing-tracy` | `hyperion` | Profiler client over a socket. |
+| `valence_anvil` | `hyperion` | Reads region files from disk. |
+| `directories` | `hyperion-utils` | Asks the OS where the home directory is. |
+| `tokio` | everywhere | Runs on Hermit **only** via `hermit-os/tokio`, pinned to 1.45.0, last commit 2025-05-08 and unmaintained. The tree pins tokio 1.45.0, so the versions line up today and will not stay lined up. `mio` and `polling` have genuine upstream Hermit support; tokio does not. |
+
+### The honest verdict
+
+Of the three outcomes the brief offered, this is the middle one, and nearer its
+lower end than its upper. A meaningful subset — the protocol codec, the ECS, the
+geometry and BVH primitives, and the wire format — compiles and one of them
+demonstrably runs. The server does not, and the distance to it is not a
+weekend: `heed`, `memmap2`, `libdeflater` and `ndarray+blas` are four C
+libraries that a unikernel with no filesystem cannot host, so closing that gap
+means replacing persistence and chunk storage, not porting them.
+
+The realistic next milestone is not "the server boots". It is **the proxy**,
+which needs only `socket2` + `tokio` forks and has no filesystem dependency
+worth the name.
+
+## The seam: five things, named after what differs
+
+`crates/hyperion-platform` is deliberately not a portability layer. Hermit
+supplies a real `std`, so almost nothing needs wrapping. The survey found five
+places where a hosted OS and a unikernel genuinely disagree, and the crate is
+those five and nothing else:
+
+| Module | Hosted | Unikernel |
+| --- | --- | --- |
+| `limits::raise_open_files` | `setrlimit(RLIMIT_NOFILE)` | no such limit; reports the ceiling asked for |
+| `clock::wall_clock` | `Some(SystemTime::now())` | `None` — no RTC unless the hypervisor gives one |
+| `storage::store` | the filesystem | RAM, seeded by the image, `is_persistent() == false` |
+| `net` | `std::net` plus `AF_UNIX` | `std::net` over virtio-net only |
+| `parallelism::available` | CPUs, as a hint | vCPUs handed over at boot, exactly |
+
+Plus a `CAPABILITIES` constant, so a call site asks "is there a filesystem?"
+rather than "am I on Unix?". The first question survives a new platform; the
+second does not.
+
+The backend is one `cfg` arm in `src/backend.rs`. Adding a third platform is a
+new `backend/*.rs`, that arm, and a row in the table. Hosted is the default and
+is byte-for-byte the behaviour hyperion already had.
+
+### What the seam is not yet
+
+**It is not wired into `hyperion`.** Not one existing crate was changed. Wiring
+it in would buy nothing today — `hyperion` is blocked on four C libraries, not
+on `cfg(unix)` — and would put a diff into files three other streams are
+editing. The demonstration that the seam works is `bare-metal/hyperion-unikernel`,
+which uses it on both platforms and reports different, correct answers on each.
+
+The call sites it is *for* are already identified, and they are few:
+
+| File | What it does |
+| --- | --- |
+| `crates/hyperion/src/lib.rs:37,94` | `libc::getrlimit`/`setrlimit`, already `#[cfg(unix)]`-gated → `limits::raise_open_files` |
+| `crates/hyperion/src/lib.rs:205` | `std::thread::Builder` with an explicit stack → `parallelism::spawn_worker` |
+| `crates/hyperion/src/common/config.rs:80,94` | `fs::create_dir_all`, `fs::write` → `storage::store` |
+| `crates/hyperion/src/storage/db.rs:24` | `fs::create_dir_all` for LMDB → `storage`, once LMDB itself is replaced |
+| `crates/hyperion/src/simulation/blocks/region.rs:30,148` | `memmap2` + `File::open` → `storage`, same caveat |
+| `crates/hyperion-proxy/src/main.rs:6,8,125` | `TcpListener`, `UnixListener`, `lookup_host` → `net::supports_unix_sockets`, `net::supports_dns` |
+| `events/bedwars/src/lib.rs` | `std::os::unix` |
+
+That is the whole OS surface of the tree. It is smaller than the dependency
+list suggests, which is the encouraging part of this exercise: hyperion's own
+code is close to portable, and its dependencies are not.
+
+## Nix
+
+Three cargo invocations happen inside one `nix build`:
+
+1. the application, from `bare-metal/Cargo.lock`;
+2. the standard library, because `-Z build-std` compiles `std` from source and
+ resolves its own lockfile out of `rust-src`;
+3. the Hermit kernel, which the `hermit` crate's `build.rs` builds by shelling
+ out to a nested cargo in the kernel's source tree.
+
+The third one is why this is a single `cargo vendor --sync` producing one
+vendor directory rather than three `fetchCargoVendor` calls: that build script
+strips **every** `CARGO_*` and `RUST_*` variable from its environment before
+running, so `$HOME/.cargo/config.toml` is the only channel that reaches it.
+
+Three further things the sandbox needed, each a one-line comment in
+`nix/bare-metal.nix`:
+
+- `CARGO_NET_GIT_FETCH_WITH_CLI=true` — cargo's bundled libgit2 cannot complete
+ a TLS handshake in a fixed-output derivation; the `git` binary can.
+- `dontFixup = true` on the vendor derivation — `patchShebangs` rewrites
+ vendored CI scripts to a bash store path, which both adds a store reference a
+ fixed-output derivation may not have and invalidates cargo's checksums.
+- A `rustup` shim — the kernel's `xtask` runs `rustup target add
+ x86_64-unknown-none` unconditionally. The shim answers that and *fails loudly*
+ on anything else, so a future rustup call does not silently no-op.
+
+`RUSTFLAGS=""` is set on the build. The repo's `.cargo/config.toml` puts
+`-Ctarget-cpu=native` in `[build] rustflags`, which applies to every target, so
+any cross-compile emits host-CPU instructions and dies inside `core`:
+
+```
+'apple-m4' is not a recognized processor for this target (ignoring processor)
+rustc-LLVM ERROR: 64-bit code requested on a subtarget that doesn't support it!
+```
+
+`RUSTFLAGS` overrides it, which is why `.cargo/config.toml` is untouched — no
+existing build changes behaviour.
+
+## Evidence
+
+Everything below is real output, on `aarch64-darwin`, cross-compiling to
+`x86_64-unknown-hermit`.
+
+### `nix build .#bare-metal`
+
+```
+$ nix build .#bare-metal --no-link -L
+...
+hyperion-unikernel> Compiling valence_protocol v0.2.0-alpha.1+mc.1.20.1 (https://github.com/TestingPlant/valence?branch=feat-bytes#fb792dcb)
+hyperion-unikernel> Compiling hyperion-unikernel v0.1.0 (/nix/var/nix/builds/nix-50057-576481663/source/bare-metal/hyperion-unikernel)
+hyperion-unikernel> Finished `release` profile [optimized] target(s) in 2m 00s
+hyperion-unikernel> Running phase: installPhase
+$ echo $?
+0
+```
+
+### `nix run .#bare-metal-vm`, then a Minecraft ping from the host
+
+The guest console. Note virtio-net negotiating features, DHCP, and the
+platform seam reporting the unikernel's capability set rather than the hosted
+one:
+
+```
+[ 0.286342][0][INFO pci ] Virtio network driver initialized.
+[ 0.289592][0][INFO network ] Try to initialize network!
+[ 0.290297][0][INFO device ] MAC address: 52-54-00-12-34-56
+[ 0.291930][0][INFO device ] ChecksumCapabilities { ipv4: Both, udp: Both, tcp: Both, icmpv4: Both, icmpv6: Both }
+[ 0.292994][0][INFO device ] MTU: 1514 bytes
+[ 0.302772][0][INFO network ] DHCP config acquired!
+[ 0.303104][0][INFO network ] IP address: 192.168.76.9/24
+[ 0.303530][0][INFO network ] Gateway: 192.168.76.2
+[ 0.310927][0][INFO hermit ] Jumping into application
+[hyperion] platform: unikernel
+[hyperion] capabilities: Capabilities { persistent_storage: false, unix_sockets: false, dns: false, trustworthy_wall_clock: false, adjustable_file_limit: false, subprocesses: false }
+[hyperion] parallelism: 2
+[hyperion] wall clock: unavailable on this platform
+[hyperion] listening on 0.0.0.0:25565 after 13.166ms
+[hyperion] accepted Ok(192.168.76.2:53071)
+[hyperion] handshake: protocol=763 host=127.0.0.1 port=25599 next=Status
+[hyperion] sent status
+[hyperion] ponged 0xdeadbeefcafef00d
+```
+
+The client's side of the same exchange:
+
+```
+=== minecraft ping from host, 127.0.0.1:25599 ===
+STATUS: {
+ "version": {
+ "name": "hyperion/unikernel",
+ "protocol": 763
+ },
+ "players": {
+ "max": 10000,
+ "online": 0,
+ "sample": []
+ },
+ "description": {
+ "text": "hyperion on unikernel, no operating system"
+ }
+}
+PONG: 0xdeadbeefcafef00d in 3.6 ms
+client rc=0
+```
+
+Boot to listening socket: **13 ms**.
+
+### The same binary on the host
+
+```
+$ HYPERION_PORT=25599 ./bare-metal/target/debug/hyperion-unikernel
+[hyperion] platform: hosted
+[hyperion] capabilities: Capabilities { persistent_storage: true, unix_sockets: true, dns: true, trustworthy_wall_clock: true, adjustable_file_limit: true, subprocesses: true }
+[hyperion] parallelism: 18
+[hyperion] wall clock: SystemTime { tv_sec: 1785138928, tv_nsec: 86423000 }
+[hyperion] listening on 0.0.0.0:25599 after 229.25µs
+[hyperion] accepted Ok(127.0.0.1:61665)
+[hyperion] handshake: protocol=763 host=127.0.0.1 port=25599 next=Status
+[hyperion] sent status
+[hyperion] ponged 0xdeadbeefcafef00d
+```
+
+One source file, two platforms, different and correct answers from the seam.
+
+### Lints
+
+```
+$ cargo clippy -p hyperion-platform --all-targets # rc=0
+$ cd bare-metal && cargo clippy # rc=0
+```
+
+## What was not verified
+
+Stated plainly, because these are the parts a reader cannot see for themselves.
+
+- **`nix build .#default` fails, and already did.** The error is
+ `A hash was specified for divan-0.1.17, but there is no corresponding git
+ dependency`. Reproduced on unmodified `313503c` in a detached worktree, so it
+ predates this branch. It does mean the existing nix path could not be used as
+ a regression check here.
+- **No full `cargo build --workspace` was run** on this branch. `cargo metadata`
+ resolves and `hyperion-platform` builds and lints clean, but the claim "the
+ normal build is unchanged" rests on the diff — no existing crate's source was
+ touched — rather than on a measurement.
+- **Linux was not tested.** Everything was built and booted on `aarch64-darwin`
+ cross-compiling to `x86_64-unknown-hermit`. QEMU ran without KVM, on
+ `-cpu Skylake-Client`.
+- **Only `x86_64-unknown-hermit`.** The aarch64 and riscv64 Hermit triples were
+ never attempted.
+- **The blocker table's second half is unmeasured.** `heed`, `memmap2`,
+ `libdeflater`, `ndarray+blas`, `tracing-tracy` and `valence_anvil` are
+ reasoned from their manifests. No build reached them.
+- **No login, no gameplay, no second connection.** The demo serves the status
+ handshake and nothing else, one connection at a time. Nothing was measured
+ under load, and no packet larger than a status response has crossed the
+ virtio-net link.
+- **Compression is off** in the demo, and the open-coded framing in
+ `bare-metal/hyperion-unikernel/src/main.rs` is only correct because of that.
+- **The Hermit forks were not exercised.** `hermit-os/socket2` and
+ `hermit-os/tokio` are named as the route to the proxy on the strength of
+ reading hermit-rs's `[patch.crates-io]` and its example set. Neither was
+ built here.
+- **Reproducibility of the vendor hash is untested** across cargo versions. It
+ is a fixed-output derivation over `cargo vendor` output, and a toolchain bump
+ will change it.
diff --git a/flake.lock b/flake.lock
index b3f043337..6eab6d279 100644
--- a/flake.lock
+++ b/flake.lock
@@ -19,7 +19,8 @@
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
- "rust-overlay": "rust-overlay"
+ "rust-overlay": "rust-overlay",
+ "rust-overlay-bare-metal": "rust-overlay-bare-metal"
}
},
"rust-overlay": {
@@ -41,6 +42,26 @@
"repo": "rust-overlay",
"type": "github"
}
+ },
+ "rust-overlay-bare-metal": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1785131767,
+ "narHash": "sha256-VNbQv2P0zgaNh96mT4LrnX7hdXgiC5nBH+uvyrrVX7U=",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "c67ce00525464a710971351c183ce67acb6ca827",
+ "type": "github"
+ },
+ "original": {
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "type": "github"
+ }
}
},
"root": "root",
diff --git a/flake.nix b/flake.nix
index 608027dd8..6b1160a3c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -7,9 +7,16 @@
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
+ # The bare-metal target needs nightly-2026-07-01, which the pinned
+ # rust-overlay above predates. Kept as a second input so that bumping it
+ # cannot move the toolchain every normal build uses.
+ rust-overlay-bare-metal = {
+ url = "github:oxalica/rust-overlay";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
};
- outputs = { self, nixpkgs, rust-overlay, ... }:
+ outputs = { self, nixpkgs, rust-overlay, rust-overlay-bare-metal, ... }:
let
forAllSystems = nixpkgs.lib.genAttrs [
"x86_64-linux"
@@ -102,6 +109,21 @@
};
};
};
+ # Experimental: hyperion's protocol layer as a unikernel image, with
+ # no operating system under it. Pinned to the Hermit kernel's own
+ # nightly, which is not the one the rest of the tree uses; see
+ # docs/bare-metal.md.
+ bareMetal = pkgs.callPackage ./nix/bare-metal.nix {
+ rustToolchain =
+ (import nixpkgs {
+ inherit system;
+ overlays = [ (import rust-overlay-bare-metal) ];
+ }).rust-bin.nightly."2026-07-01".default.override {
+ extensions = [ "rust-src" "llvm-tools" ];
+ # The kernel's loader stub is built for x86_64-unknown-none.
+ targets = [ "x86_64-unknown-none" ];
+ };
+ };
in
{
devShells.default = pkgs.mkShell {
@@ -113,10 +135,18 @@
default = hyperion;
docker-hyperion-proxy = hyperion-proxy-image;
docker-bedwars = bedwars-image;
+ bare-metal = bareMetal.image;
+ bare-metal-vendor = bareMetal.image.passthru.vendor;
+ };
+
+ apps.bare-metal-vm = {
+ type = "app";
+ program = pkgs.lib.getExe bareMetal.vm;
};
};
in
{
+ apps = forAllSystems (system: (mkSystem system).apps);
devShells = forAllSystems (system: (mkSystem system).devShells);
packages = forAllSystems (system: (mkSystem system).packages);
};
diff --git a/nix/bare-metal.nix b/nix/bare-metal.nix
new file mode 100644
index 000000000..b1d222a78
--- /dev/null
+++ b/nix/bare-metal.nix
@@ -0,0 +1,196 @@
+# Building a Tier-3 Rust target with a custom sysroot is exactly the kind of
+# thing that works on the machine it was invented on and nowhere else, which is
+# why it lives here rather than in a shell script.
+#
+# Three cargo invocations happen inside one build:
+#
+# 1. the application, from bare-metal/Cargo.lock;
+# 2. the standard library, because -Z build-std compiles std from source and
+# resolves its own lockfile out of rust-src;
+# 3. the Hermit kernel, which the `hermit` crate's build script builds by
+# shelling out to a nested cargo in the kernel's source tree.
+#
+# All three read one vendor directory, produced by a single `cargo vendor
+# --sync`, because the third one strips every CARGO_* and RUST_* variable from
+# its environment before running and can only be reached through
+# $HOME/.cargo/config.toml.
+{
+ cacert,
+ fetchFromGitHub,
+ fetchurl,
+ git,
+ lib,
+ qemu,
+ rustToolchain,
+ stdenvNoCC,
+ writeShellApplication,
+ writeShellScriptBin,
+}:
+let
+ target = "x86_64-unknown-hermit";
+
+ # Pinned to what the `hermit` crate at tag hermit-0.13.2 carries as its kernel
+ # submodule. Cargo's vendoring does not follow submodules, so the kernel is
+ # fetched separately and handed to the build script with HERMIT_MANIFEST_DIR.
+ kernelSrc = fetchFromGitHub {
+ owner = "hermit-os";
+ repo = "kernel";
+ rev = "f51061476ecaa2066779c473a683d8b35b315d9b";
+ hash = "sha256-k6vf89oW/69exw8qblFu6v5n/+DK/t5LC+MaMijF+Es=";
+ };
+
+ # The loader is what QEMU boots as -kernel; it unpacks the image and jumps
+ # into it. Taken prebuilt because building it means a fourth toolchain.
+ loader = fetchurl {
+ url = "https://github.com/hermit-os/loader/releases/download/v0.5.6/hermit-loader-x86_64";
+ hash = "sha256-GF9+yEOhhISqchguynf/PgcMBBqOo18rG0FTCNZz1Uk=";
+ };
+
+ rustSrc = "${rustToolchain}/lib/rustlib/src/rust/library";
+
+ # The kernel's xtask runs `rustup target add x86_64-unknown-none` before
+ # building. There is no rustup here and the toolchain already carries that
+ # target, so answer yes and get out of the way. Anything else rustup is asked
+ # to do is a real gap, so fail loudly rather than silently succeeding.
+ rustupShim = writeShellScriptBin "rustup" ''
+ if [ "$1" = "target" ] && [ "$2" = "add" ]; then
+ exit 0
+ fi
+ echo "nix/bare-metal.nix: unhandled rustup invocation: $*" >&2
+ exit 1
+ '';
+
+ src = lib.fileset.toSource {
+ root = ../.;
+ fileset = lib.fileset.unions [
+ ../bare-metal
+ ../crates/hyperion-platform
+ ];
+ };
+
+ # One vendor directory for all three cargo runs. `--sync` is the only way to
+ # get a single consistent set: vendoring each manifest separately produces
+ # three `config.toml` fragments that have to be merged by hand, and any
+ # mistake in the merge shows up as a network fetch in a sandbox.
+ vendor = stdenvNoCC.mkDerivation {
+ name = "hyperion-bare-metal-vendor";
+ inherit src;
+
+ # A fixed-output derivation has network access but nothing else from the
+ # host, so the certificate bundle and git have to be named explicitly.
+ nativeBuildInputs = [
+ cacert
+ git
+ rustToolchain
+ ];
+
+ buildPhase = ''
+ runHook preBuild
+ export HOME="$TMPDIR/home"
+ export SSL_CERT_FILE="${cacert}/etc/ssl/certs/ca-bundle.crt"
+ # cargo's bundled libgit2 cannot complete a TLS handshake in the sandbox;
+ # the git binary can.
+ export CARGO_NET_GIT_FETCH_WITH_CLI=true
+ mkdir -p "$out"
+ cargo vendor --locked \
+ --manifest-path bare-metal/Cargo.toml \
+ --sync ${rustSrc}/Cargo.toml \
+ --sync ${kernelSrc}/Cargo.toml \
+ --sync ${kernelSrc}/hermit-builtins/Cargo.toml \
+ --sync ${kernelSrc}/hermit-macro/Cargo.toml \
+ "$out/vendor" > "$out/config.toml"
+ # A fixed-output derivation may not reference store paths, and cargo writes
+ # the absolute vendor directory into the config it emits. Leave a
+ # placeholder for the consumer to fill in.
+ substituteInPlace "$out/config.toml" --replace-fail "$out/vendor" "@vendor@"
+ runHook postBuild
+ '';
+
+ dontInstall = true;
+ # patchShebangs would rewrite vendored CI scripts to point at a bash in the
+ # store, which both adds a reference and invalidates cargo's checksums.
+ dontFixup = true;
+
+ outputHashAlgo = "sha256";
+ outputHashMode = "recursive";
+ outputHash = "sha256-bCu5zrjxq78+s5dkFiYMJynosdTKHe1bKf5uWWbTzNk=";
+ };
+
+ image = stdenvNoCC.mkDerivation {
+ pname = "hyperion-unikernel";
+ version = "0.1.0";
+ inherit src;
+
+ nativeBuildInputs = [
+ rustToolchain
+ rustupShim
+ ];
+
+ # The repo's .cargo/config.toml adds -Ctarget-cpu=native to every build,
+ # which makes any cross-compile emit host-CPU instructions. RUSTFLAGS wins
+ # over it, so set it here rather than editing a file every other build
+ # reads.
+ RUSTFLAGS = "";
+
+ HERMIT_MANIFEST_DIR = kernelSrc;
+
+ buildPhase = ''
+ runHook preBuild
+
+ export HOME="$TMPDIR/home"
+ export CARGO_HOME="$HOME/.cargo"
+ mkdir -p "$CARGO_HOME"
+ substitute ${vendor}/config.toml "$CARGO_HOME/config.toml" \
+ --replace-fail '@vendor@' '${vendor}/vendor'
+
+ cargo build \
+ --offline \
+ --manifest-path bare-metal/Cargo.toml \
+ --release \
+ --target ${target} \
+ -Z build-std=std,panic_abort
+
+ runHook postBuild
+ '';
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p "$out/share/hyperion"
+ cp bare-metal/target/${target}/release/hyperion-unikernel "$out/share/hyperion/image"
+ cp ${loader} "$out/share/hyperion/loader"
+ runHook postInstall
+ '';
+
+ passthru = { inherit kernelSrc loader vendor; };
+
+ meta = {
+ description = "hyperion's protocol layer as a Hermit unikernel image";
+ platforms = lib.platforms.all;
+ };
+ };
+
+ # Host port differs from the guest's on purpose: 25565 is usually already
+ # taken by a real server on a developer's machine, and forwarding onto it
+ # would silently talk to that instead of the VM.
+ vm = writeShellApplication {
+ name = "hyperion-bare-metal-vm";
+ runtimeInputs = [ qemu ];
+ text = ''
+ host_port="''${HYPERION_HOST_PORT:-25599}"
+ echo "booting hyperion unikernel; ping 127.0.0.1:$host_port as a Minecraft server" >&2
+ exec qemu-system-x86_64 \
+ -display none -serial stdio \
+ -kernel ${image}/share/hyperion/loader \
+ -initrd ${image}/share/hyperion/image \
+ -smp "''${HYPERION_SMP:-2}" -m "''${HYPERION_MEM:-1024M}" \
+ -cpu Skylake-Client \
+ -device isa-debug-exit,iobase=0xf4,iosize=0x04 \
+ -netdev "user,id=net0,hostfwd=tcp::$host_port-:25565,net=192.168.76.0/24,dhcpstart=192.168.76.9" \
+ -device virtio-net-pci,netdev=net0,disable-legacy=on,packed=on,mq=on \
+ "$@"
+ '';
+ };
+in
+{
+ inherit image vm;
+}